From 9de446bbe146e02c5e7df80c5932d7cfd73eb37e Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 5 Jan 2026 22:05:52 -0800 Subject: [PATCH 01/11] chore: Add PLAN.md --- PLAN.md | 603 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 603 insertions(+) create mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 000000000..af3dee21c --- /dev/null +++ b/PLAN.md @@ -0,0 +1,603 @@ +# Omnium plan + +## TODO + +### Phase 1: Caplet Installation and Service Discovery + +This phase focuses on establishing the foundational architecture for Caplets: +defining their structure, implementing installation mechanics, and creating a +service discovery mechanism that allows Caplets to communicate using object +capabilities. + +#### 1.0 Omnium dev console + +- [ ] Extension background dev console implementation + + - Add `globalThis.omnium` in `background.ts` + - Model this on `globalThis.kernel` in @packages/extension + - This "dev console" object is how we expose remote objects and + other functionality in the dev console + +#### 1.1 Build Userspace E() Infrastructure + +**Goal**: Enable userspace (background script) to use `E()` naturally with kernel and vat objects, establishing the foundation for omnium ↔ kernel ↔ vat communication. + +**Architecture**: Create **remote presences** in userspace that represent vat objects. These presences work directly with `E()`, maintaining consistent eventual-send semantics across the kernel boundary. No special "call method on object" RPC needed - just create presences from krefs and use E() naturally. + +- [ ] **Remote Presence Implementation** + + - Create `makeRemotePresence(kref)` function in userspace + - Returns an object that works with `E()` from `@endo/eventual-send` + - Implemented using Proxy or far object handlers to intercept method calls + - When `E(remotePresence).method(args)` is invoked: + 1. Intercepts the method call + 2. Serializes arguments (handling nested object references) + 3. Sends RPC message: `{ type: 'send', target: kref, method, args }` + 4. Kernel routes to appropriate vat + 5. Deserializes result and resolves promise + - Location: `packages/omnium-gatherum/src/kernel/remote-presence.ts` + +- [ ] **Kernel Message Routing** + + - Kernel receives RPC messages from userspace with target krefs + - Routes to appropriate vat objects (kernel services or vat-exported objects) + - Handles promise resolution back to userspace + - May require new RPC method or extension of existing message handling + - Location: Likely in `packages/ocap-kernel/src/rpc/` or kernel router + +- [ ] **Kernel Remote Presence** (Optional) + + - For convenience, expose kernel itself as a remote presence + - Allows `E(kernel).launchSubcluster(config)` from userspace + - Alternative: Kernel could remain RPC-based if simpler + - Decision: TBD based on implementation complexity + +- [ ] **Argument Serialization** + + - Handle serialization of arguments that may contain object references + - Pass-by-reference: Other krefs in arguments should be preserved + - Pass-by-copy: Plain data (JSON-serializable) should be copied + - Use CapData format (same as vat-to-vat communication) + - Location: `packages/omnium-gatherum/src/kernel/serialization.ts` + +- [ ] **Promise Management** + + - Handle async results and promise resolution across userspace/kernel boundary + - Consider: Do we support promise pipelining in Phase 1? + - Minimal: Just support eventual send with promise resolution + - Advanced: Support pipelining (E(E(foo).bar()).baz()) + - Phase 1 recommendation: Just promise resolution, defer pipelining + +- [ ] **Testing** + - Unit tests: + - Create remote presence from kref + - Invoke methods with E() + - Verify RPC messages are correctly formatted + - Test serialization/deserialization + - Integration tests: + - Launch a test vat + - Get its root kref from launch result + - Create remote presence + - Call methods via E() from userspace + - Verify results come back correctly + - Error handling: + - Test method throws error + - Test vat terminated during call + - Test invalid kref + +**Note**: This infrastructure is foundational. By creating remote presences that work with `E()`, userspace code looks identical to vat code. This is the right abstraction for ocap model - omnium can interact with any vat object using the same eventual-send patterns. + +#### 1.2 Define Caplet Structure + +**Goal**: Establish the data structures and formats that define a Caplet. + +- [ ] **Caplet Manifest Schema** + + - Define a TypeScript type/superstruct for Caplet metadata: + - `id`: Unique identifier (string, e.g., `"com.example.bitcoin-signer"`) + - `name`: Human-readable name + - `version`: Semantic version + - `bundleSpec`: URI to the vat bundle (for now, local file paths or inline bundles) + - `requestedServices`: Array of service names this Caplet wants to consume (e.g., `["keyring", "network"]`) + - `providedServices`: Array of service names this Caplet exposes (e.g., `["bitcoin-signer"]`) + - `description`: Optional description + - `author`: Optional author info + - Location: Create `packages/omnium-gatherum/src/caplet/types.ts` + +- [ ] **Caplet Vat Bundle Format** + + - A Caplet's code is a standard vat bundle (JSON output from `@endo/bundle-source`) + - The vat must export `buildRootObject(vatPowers, parameters, baggage)` as per kernel conventions + - The root object should implement a standard Caplet interface: + - `initialize(services)`: Receives requested services, returns own service interface(s) + - `shutdown()`: Cleanup hook + - Document the Caplet vat contract in `packages/omnium-gatherum/docs/caplet-contract.md` + +- [ ] **Caplet Storage Schema** + - Define how installed Caplets are persisted in **user space** (not kernel store): + - Use **Chrome Storage API** (`chrome.storage.local`) for omnium-specific data + - Maintains clean kernel/user space separation - kernel doesn't know about Caplets + - Storage keys: + - `caplet.${capletId}.manifest` → JSON manifest + - `caplet.${capletId}.subclusterId` → Associated subcluster ID + - `caplet.installed` → Array of installed Caplet IDs + - Location: `packages/omnium-gatherum/src/caplet/storage.ts` + - Note: This is omnium's own storage, separate from kernel store + +#### 1.3 Implement Caplet Installation + +**Goal**: Enable loading a Caplet into omnium, creating its subcluster, and registering it. + +- [ ] **Caplet Installation Service (Non-Vat Code)** + + - Create `packages/omnium-gatherum/src/caplet/installer.ts` + - Implement `CapletInstaller` class that: + - Validates Caplet manifest + - Loads vat bundle (from URL or inline) + - Resolves requested services from Chrome storage (canonical source of truth) + - Creates a ClusterConfig for the Caplet: + - Single vat named after the Caplet ID + - Bootstrap vat is the Caplet itself + - **Phase 1**: Pass resolved service krefs directly via bootstrap arguments + - Calls `E(kernel).launchSubcluster(config)` (using userspace E() infrastructure) + - Captures returned Caplet root kref + - Stores Caplet manifest, subcluster ID, and root kref in Chrome storage + - Returns installation result (success/failure + subcluster ID + kref) + +- [ ] **Bundle Loading Utilities** + + - Support multiple bundle sources: + - Inline bundle (passed as JSON) + - Local file path (for development) + - HTTP(S) URL (fetch bundle remotely) + - Use existing `@endo/bundle-source` for creating bundles + - Location: `packages/omnium-gatherum/src/caplet/bundle-loader.ts` + +- [ ] **Installation Lifecycle** + - On install: + 1. Validate manifest + 2. Load bundle + 3. Resolve requested services (lookup krefs from Chrome storage) + 4. Create subcluster, passing resolved service krefs in bootstrap + 5. Capture Caplet's root kref from launch result + 6. Store Caplet metadata (manifest, subcluster ID, root kref) in Chrome storage + 7. **Phase 1**: Direct reference passing - Caplet receives services immediately + - Handle installation errors (rollback if possible) + +**Phase 1 Approach**: Services are resolved at install time and passed directly to Caplets. No dynamic service discovery in Phase 1 - this enables us to reach PoC faster without building the full registry vat architecture. + +#### 1.4 Create Omnium Service Registry (DEFERRED to Phase 2) + +**Goal**: Provide dynamic service discovery where Caplets can register services and request capabilities at runtime. + +**Architecture Decision**: The service registry will be a **"well-known" vat** that omnium populates with service data from Chrome storage (the canonical source of truth). + +**Status**: **Deferred to Phase 2**. Phase 1 uses direct reference passing for PoC. + +**Future Architecture (Phase 2+)**: + +- [ ] **TODO: Design revocable service connections** + + - Service connections need to be revocable (not just direct object references) + - Consider: membrane pattern, revocable proxies, explicit grant/revoke lifecycle + - Who can revoke? Omnium? Service provider? User? + - What happens to in-flight messages when revoked? + - How do we represent revocation in the UI? + +- [ ] **Service Registry Vat** (Phase 2) + + - Create `packages/omnium-gatherum/src/vats/registry-vat.js` + - Implement a vat that exports `buildRootObject()` returning a registry exo + - Methods: + - `registerService(capletId, serviceName, serviceObject)`: Associates service with Caplet + - `getService(serviceName)`: Returns service object (or revocable proxy) + - `listServices()`: Returns available services + - `unregisterCapletServices(capletId)`: Cleanup on uninstall + - `revokeService(capletId, serviceName)`: Revoke a specific service grant + - **Note**: Registry vat's baggage may be minimal or empty - it's primarily a mediator + - Omnium populates it with data from Chrome storage using E() + +- [ ] **Omnium Populates Registry** (Phase 2) + + - After installing a Caplet: + 1. Omnium launches the Caplet, captures its root kref + 2. Omnium calls `E(registry).registerService(capletId, serviceName, capletKref)` + 3. Registry vat now knows about this service + - When a Caplet requests a service: + 1. Caplet calls `E(registry).getService(serviceName)` + 2. Registry returns the provider's kref (or revocable proxy) + - Canonical state: Chrome storage + - Registry vat: Derived state, populated by omnium + +- [ ] **Caplet Service Registration Flow** (Phase 2) + - All Caplets receive registry vat reference in bootstrap + - Dynamic discovery: Caplets can request services at runtime + - Revocation: Connections can be terminated, must handle gracefully + +**Phase 1 Approach**: Skip registry vat entirely. Services resolved at install time and passed directly to Caplets via bootstrap arguments. This gets us to a working PoC faster while we design the revocation model. + +#### 1.5 Caplet Communication Protocol + +**Goal**: Define how Caplets use capabilities from other Caplets. + +- [ ] **Phase 1: Direct Reference Pattern** + + - Document the flow in `packages/omnium-gatherum/docs/service-discovery.md`: + 1. Caplet A's manifest declares `requestedServices: ["bitcoin"]` + 2. Omnium looks up bitcoin service provider (Caplet B) in Chrome storage + 3. Omnium retrieves Caplet B's root kref + 4. Omnium passes Caplet B's kref to Caplet A in bootstrap: `bootstrap(vats, { bitcoin: capletBKref })` + 5. Caplet A uses `E(bitcoin).someMethod()` to invoke methods + 6. Messages are routed through kernel (standard vat-to-vat messaging) + - **Limitation**: Services resolved at install time, no runtime discovery + - **Benefit**: Simple, no registry vat needed for PoC + +- [ ] **Phase 2+: Dynamic Discovery Pattern** (Deferred) + + - Caplets receive registry vat reference + - Can request services at runtime: `E(registry).getService("someService")` + - Services can be revoked + - More flexible but requires registry vat infrastructure + +- [ ] **Service Interface Conventions** + - Define recommended patterns for service interfaces: + - Use async methods (return promises) + - Accept/return serializable data or object references + - Document expected methods in service interface types + - Create example service interfaces in `packages/omnium-gatherum/src/services/interfaces.ts` + +#### 1.6 Dev Console Integration + +**Goal**: Make Caplet installation usable from the Chrome DevTools console. + +- [ ] **Expose Caplet Operations on globalThis.omnium** + + - In omnium's background script (`packages/omnium-gatherum/src/background.ts`), add: + - `kernel.caplet.install(manifest, bundle)`: Install a Caplet + - `manifest`: Caplet manifest object + - `bundle`: Inline bundle JSON, file path, or URL + - Returns: `Promise<{ capletId, subclusterId }>` + - `kernel.caplet.list()`: List installed Caplets + - Returns: `Promise>` + - `kernel.caplet.uninstall(capletId)`: Uninstall a Caplet + - Terminates its subcluster and removes from storage + - `kernel.service.list()`: List all registered services + - Returns: `Promise>` + - `kernel.service.get(serviceName)`: Get a service by name + - Returns: `Promise` + - Harden `kernel.caplet` and `kernel.service` objects + +- [ ] **Example Usage in Console** + + - Create test Caplets in `packages/omnium-gatherum/test/fixtures/`: + - `echo-caplet`: Simple Caplet that registers an "echo" service + - `consumer-caplet`: Caplet that discovers and calls the "echo" service + - Document console commands in `packages/omnium-gatherum/docs/dev-console-usage.md`: + + ```javascript + // Install echo Caplet + await kernel.caplet.install( + { + id: 'com.example.echo', + name: 'Echo Service', + version: '1.0.0', + bundleSpec: '/path/to/echo.bundle', + providedServices: ['echo'], + }, + echoBundle, + ); + + // List installed Caplets + await kernel.caplet.list(); + + // List services + await kernel.service.list(); + + // Install consumer Caplet that uses echo + await kernel.caplet.install(consumerManifest, consumerBundle); + ``` + +#### 1.7 Testing + +**Goal**: Validate that Caplets can be installed and communicate with each other. + +- [ ] **Unit Tests** + + - `packages/omnium-gatherum/src/caplet/types.test.ts`: Validate manifest schema + - `packages/omnium-gatherum/src/caplet/installer.test.ts`: Test installation logic + - `packages/omnium-gatherum/src/services/service-registry.test.ts`: Test service registration/discovery + +- [ ] **Integration Tests** + + - `packages/omnium-gatherum/test/caplet-integration.test.ts`: + - Install two Caplets + - Verify one can discover and call the other's service + - Verify message passing works correctly + - Test uninstallation + +- [ ] **E2E Tests (Playwright)** + - `packages/omnium-gatherum/test/e2e/caplet.spec.ts`: + - Load omnium extension in browser + - Use console to install Caplets + - Verify they can communicate + - Check DevTools console output + +#### 1.8 Documentation + +- [ ] **Architecture Documentation** + + - Create `packages/omnium-gatherum/docs/architecture.md`: + - Explain how Caplets relate to subclusters and vats + - Diagram showing omnium → kernel → Caplet subclusters + - Userspace E() infrastructure + - Phase 1: Direct reference passing vs Phase 2: Dynamic service discovery + +- [ ] **Developer Guide** + - Create `packages/omnium-gatherum/docs/caplet-development.md`: + - How to write a Caplet vat + - Service registration examples + - Requesting services from other Caplets + - Testing Caplets locally + +--- + +### Future Phases: UI Architecture + +**Context**: Phase 1 focuses on headless Caplets with dev console interaction only. This section outlines the vision for how Caplets will eventually provide user-facing UI while maintaining security and composability. + +#### Core Principles + +1. **Zero trust for Caplet UI code**: Caplet-provided UI code must not run in privileged extension contexts +2. **Composability**: Multiple Caplets' UIs should compose naturally into a cohesive experience +3. **Security isolation**: Caplet UI should be isolated from other Caplets and omnium's privileged code +4. **User experience**: UI should feel cohesive, not fragmented + +#### Phase 2: Declarative UI Contributions + +**Goal**: Enable Caplets to describe their data and capabilities using a safe, declarative format that Omnium renders using trusted UI components. + +- **Caplet UI Manifest**: + + - Caplets declare what they provide via structured metadata (not code): + - Account types: `{ type: "bitcoin", properties: ["address", "balance", "publicKey"] }` + - Actions: `{ name: "signTransaction", inputs: [...], confirmation: "Show tx details" }` + - Settings: `{ name: "Network", type: "select", options: [...] }` + - Similar to how native apps declare permissions and intents + +- **Omnium UI Framework**: + + - Provides trusted, pre-built UI components: + - Account list view (renders all accounts from all Caplets) + - Transaction confirmation modal + - Settings panels + - Status indicators + - Caplets' data flows into these components + - Omnium controls all rendering (no Caplet code execution in UI context) + +- **Data Flow**: + + ``` + Caplet vat → Service methods → RPC → Background → Omnium UI components → Rendered UI + ``` + +- **Benefits**: + + - Caplets customize UX without providing arbitrary code + - Omnium maintains UX consistency + - Security: Only trusted omnium code renders UI + - Composability: Multiple Caplets' data can be combined in standard views + +- **Limitations**: + - Caplets cannot provide fully custom UX + - Limited to omnium's predefined UI patterns + - Novel UI patterns require omnium updates + +#### Phase 3: Isolated UI Frames (Advanced) + +**Goal**: Allow Caplets to provide custom UI for complex use cases while maintaining security isolation. + +- **Architecture**: + + - Caplets can optionally provide UI content served in isolated iframes + - Each Caplet's UI runs in a separate iframe with strict CSP + - Communication between Caplet UI and Caplet vat via postMessage/RPC + - Caplet UI cannot access other Caplets or omnium privileged APIs + +- **UI Composition Challenges**: + + - Multiple iframes are harder to compose into cohesive UX + - Cross-frame communication complexity + - Performance and visual consistency concerns + +- **Possible Solutions**: + + - Web Components: Caplets define custom elements that omnium can compose + - Shadow DOM for style isolation + - Standardized theming/design tokens for visual consistency + - Message bus for inter-Caplet UI communication (mediated by omnium) + +- **Research Questions**: + - Can we achieve seamless composition with iframe-based isolation? + - Are Web Components + Shadow DOM sufficient for security isolation? + - How do we handle shared state (e.g., global loading indicators, modals)? + - Can we use technologies like import maps with module federation for safer code loading? + +#### Phase 4: Trusted UI Plugins (Speculative) + +**Goal**: Separate the trust model for UI from backend Caplet logic. + +- **Two-tier system**: + + - **Caplets**: Headless services (untrusted, fully sandboxed) + - **UI Plugins**: Separate entities that call Caplet services (potentially more trusted) + +- **UI Plugin Trust Model**: + + - UI plugins go through different review/curation + - May have different permission model + - Could run in less-sandboxed contexts if they meet trust requirements + - Users explicitly install UI plugins separately from backend Caplets + +- **Benefits**: + + - Flexibility: Same backend Caplet can have multiple UIs + - Security: Can have stricter requirements for UI plugins + - Separation: Backend and frontend evolve independently + +- **Challenges**: + - More complex installation/discovery + - Coordination between Caplet and UI plugin developers + - User confusion about two types of plugins + +#### Open Research Questions + +1. **Secure UI composition**: Is it possible to achieve truly composable UI while maintaining strong security isolation? +2. **Web platform primitives**: Can we leverage Web Components, Shadow DOM, import maps, etc. effectively? +3. **User experience**: How do we maintain UX cohesion with third-party UI contributions? +4. **Performance**: What's the overhead of iframe/web component isolation? +5. **Developer experience**: How do we make it easy to build Caplet UIs within constraints? + +#### Recommendation for Phase 1 + +For Phase 1, **defer all UI architecture decisions**: + +- Caplets are purely headless services +- Dev console provides all interaction +- This gives us time to research and experiment with UI approaches +- Backend architecture (service discovery, vat communication) is orthogonal to UI + +--- + +### Open Questions / Design Decisions for Phase 1 + +1. **One vat vs. multiple vats per Caplet?** + + - Start with one vat per Caplet (simplest) + - A Caplet can launch multiple vats if needed by creating its own sub-subcluster + +2. **Capability approval mechanism?** + + - Phase 1: No approval UI, services are freely accessible once registered + - Phase 2: Add approval prompts before granting service access + +3. **Service naming conflicts?** + + - Phase 1: Last-registered wins + - Phase 2: Support namespacing or multiple providers + +4. **Where does omnium's own code run?** + + - Background script: Installation management, E() calls to kernel, Chrome storage for metadata (canonical) + - Phase 1: No registry vat (services passed directly) + - Phase 2+: Registry vat for dynamic discovery (omnium-populated, revocable connections) + - Caplets: Each in their own subcluster + - Clean separation: kernel knows nothing about Caplets, only vats/subclusters + +5. **Bundle storage?** + + - Phase 1: Bundles are ephemeral, not stored (must re-provide on install) + - Phase 2: Store bundles in Chrome storage or IndexedDB for persistence across restarts + - Never in kernel store - maintains user/kernel space separation + +6. **How do Caplets receive service references?** + + - Phase 1: Via bootstrap arguments - resolved krefs passed directly (e.g., `bootstrap(vats, { bitcoin: kref })`) + - Phase 2+: Via registry vat - dynamic discovery at runtime + +7. **Userspace E() infrastructure** + - Critical foundation: Enables omnium to use E() to interact with kernel and vat objects + - Kernel exposes exo interface + - Userspace creates remote proxies to vat objects using returned krefs + - This is how omnium will populate the registry vat in Phase 2 + +## High-level plan + +### Components Built Into Omnium Directly + +These are the core distribution components that ship with omnium-gatherum: + +1. Extension Shell + +- Background service worker orchestration +- Offscreen document for kernel isolation +- Popup interface +- DevTools integration +- Communication with third-party context via `externally_connectable` + +2. Kernel Integration Layer + +- Kernel worker initialization and lifecycle management +- RPC client/server plumbing between extension contexts +- Stream-based IPC infrastructure +- Storage initialization and migration + +3. Caplet Management UI + +- Install/uninstall Caplets interface +- View all installed Caplets with versions +- Update management (review diffs, approve updates, pin versions) +- Search/browse Caplets from configured registries +- Direct installation by CID (for uncensored access) + +4. Capability Management System + +- Capability grant approval UI (shown on install and at runtime) +- Revocation controls for active capabilities +- Attenuation interface (time limits, rate limits, scoping) +- Capability audit log/visualization +- Inter-Caplet capability delegation review + +5. Security & Trust UI + +- Risk labels and warnings +- Attestation display (audits, security reviews, community ratings) +- Requested capabilities review on install +- Code diff viewer for updates +- Emergency quarantine controls (opt-in to DAO flags) +- Reproducible build verification status + +6. Wallet Configuration Management + +- Blueprint export/import (save/restore entire wallet setup) +- Registry management (add/remove registries) +- Settings and preferences +- Backup/recovery workflows (delegates to installed signer Caplets) + +7. Bootstrap Experience + +- First-run setup flow +- Default registry configuration +- Possibly a minimal set of "blessed" initial Caplets (or truly zero - TBD) +- Onboarding education about the Caplet model + +### Caplet Ecosystem Support (External Components) + +These enable the permissionless, decentralized Caplet ecosystem: + +1. Publishing Infrastructure + +- IPFS pinning services, deterministic builds, code signing tools, registry + registration protocol + +2. Registry System + +- Onchain registry contracts, multiple independent registries, curation + mechanisms (staking, slashing), search/discovery APIs + +3. Governance & Economics + +- TBD + +4. Security & Attestation + +- Auditor network, bug bounty platform, attestation publication (EAS/DIDs), + continuous monitoring + +5. Developer Tooling + +- Caplet SDK (TypeScript), testing harness for sandbox behavior, build/publish + CLI, reference implementations and templates, capability protocol documentation + +The key distinction: omnium is the user-facing distribution that makes the +kernel usable, while the ecosystem components enable the permissionless +marketplace of Caplets that omnium consumers can install. From 8291037ce47d4ae0ac29d50754e984ad01d2a73f Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 5 Jan 2026 22:07:00 -0800 Subject: [PATCH 02/11] feat(omnium): Add dev console object to background --- PLAN.md | 2 +- packages/omnium-gatherum/src/background.ts | 8 ++++++++ .../src/env/background-trusted-prelude.js | 3 +++ .../omnium-gatherum/src/env/dev-console.js | 9 +++++++++ .../src/env/dev-console.test.ts | 20 +++++++++++++++++++ packages/omnium-gatherum/src/global.d.ts | 9 +++++++++ packages/omnium-gatherum/vite.config.ts | 9 ++++++--- 7 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 packages/omnium-gatherum/src/env/background-trusted-prelude.js create mode 100644 packages/omnium-gatherum/src/env/dev-console.js create mode 100644 packages/omnium-gatherum/src/env/dev-console.test.ts create mode 100644 packages/omnium-gatherum/src/global.d.ts diff --git a/PLAN.md b/PLAN.md index af3dee21c..494156120 100644 --- a/PLAN.md +++ b/PLAN.md @@ -11,7 +11,7 @@ capabilities. #### 1.0 Omnium dev console -- [ ] Extension background dev console implementation +- [x] Extension background dev console implementation - Add `globalThis.omnium` in `background.ts` - Model this on `globalThis.kernel` in @packages/extension diff --git a/packages/omnium-gatherum/src/background.ts b/packages/omnium-gatherum/src/background.ts index 7b2b07ba4..19c3ddedc 100644 --- a/packages/omnium-gatherum/src/background.ts +++ b/packages/omnium-gatherum/src/background.ts @@ -92,6 +92,14 @@ async function main(): Promise { logger.info(result); }; + // globalThis.omnium will exist due to dev-console.js in background-trusted-prelude.js + Object.defineProperties(globalThis.omnium, { + ping: { + value: ping, + }, + }); + harden(globalThis.omnium); + // With this we can click the extension action button to wake up the service worker. chrome.action.onClicked.addListener(() => { ping().catch(logger.error); diff --git a/packages/omnium-gatherum/src/env/background-trusted-prelude.js b/packages/omnium-gatherum/src/env/background-trusted-prelude.js new file mode 100644 index 000000000..d026032b6 --- /dev/null +++ b/packages/omnium-gatherum/src/env/background-trusted-prelude.js @@ -0,0 +1,3 @@ +// eslint-disable-next-line import-x/no-unresolved +import './endoify.js'; +import './dev-console.js'; diff --git a/packages/omnium-gatherum/src/env/dev-console.js b/packages/omnium-gatherum/src/env/dev-console.js new file mode 100644 index 000000000..7c5d06d5e --- /dev/null +++ b/packages/omnium-gatherum/src/env/dev-console.js @@ -0,0 +1,9 @@ +// We set this property on globalThis in the background before lockdown. +Object.defineProperty(globalThis, 'omnium', { + configurable: false, + enumerable: true, + writable: false, + value: {}, +}); + +export {}; diff --git a/packages/omnium-gatherum/src/env/dev-console.test.ts b/packages/omnium-gatherum/src/env/dev-console.test.ts new file mode 100644 index 000000000..0e7ad3f15 --- /dev/null +++ b/packages/omnium-gatherum/src/env/dev-console.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; +import './dev-console.js'; + +describe('dev-console', () => { + describe('omnium', () => { + it('is available on globalThis', async () => { + expect(omnium).toBeDefined(); + }); + + it('has expected property descriptors', async () => { + expect( + Object.getOwnPropertyDescriptor(globalThis, 'omnium'), + ).toMatchObject({ + configurable: false, + enumerable: true, + writable: false, + }); + }); + }); +}); diff --git a/packages/omnium-gatherum/src/global.d.ts b/packages/omnium-gatherum/src/global.d.ts new file mode 100644 index 000000000..25566171c --- /dev/null +++ b/packages/omnium-gatherum/src/global.d.ts @@ -0,0 +1,9 @@ +// Type declarations for omnium dev console API. +declare global { + // eslint-disable-next-line no-var + var omnium: { + ping: () => Promise; + }; +} + +export {}; diff --git a/packages/omnium-gatherum/vite.config.ts b/packages/omnium-gatherum/vite.config.ts index 1caf51ceb..c1a8f2a2d 100644 --- a/packages/omnium-gatherum/vite.config.ts +++ b/packages/omnium-gatherum/vite.config.ts @@ -37,16 +37,19 @@ const staticCopyTargets: readonly (string | Target)[] = [ // The extension manifest 'packages/omnium-gatherum/src/manifest.json', // Trusted prelude-related + 'packages/omnium-gatherum/src/env/dev-console.js', + 'packages/omnium-gatherum/src/env/background-trusted-prelude.js', 'packages/kernel-shims/dist/endoify.js', ]; +const backgroundPreludeImportStatement = `import './background-trusted-prelude.js';`; const endoifyImportStatement = `import './endoify.js';`; -const trustedPreludes: PreludeRecord = { +const trustedPreludes = { background: { - content: endoifyImportStatement, + content: backgroundPreludeImportStatement, }, 'kernel-worker': { content: endoifyImportStatement }, -}; +} satisfies PreludeRecord; // https://vitejs.dev/config/ export default defineConfig(({ mode }) => { From c71207d024ce7832483f3cff9fdf4c01427d417d Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 5 Jan 2026 22:08:41 -0800 Subject: [PATCH 03/11] chore: Move PLAN.md to omnium package --- PLAN.md => packages/omnium-gatherum/PLAN.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename PLAN.md => packages/omnium-gatherum/PLAN.md (100%) diff --git a/PLAN.md b/packages/omnium-gatherum/PLAN.md similarity index 100% rename from PLAN.md rename to packages/omnium-gatherum/PLAN.md From 0abbec3618934d00e6043365bf902f4b067227d6 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 6 Jan 2026 14:59:47 -0800 Subject: [PATCH 04/11] feat(omnium): Add CapTP-based E() infrastructure for kernel communication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements userspace E() infrastructure using @endo/captp to enable the background script to use E() naturally with kernel objects. Key changes: - Add CapTP setup on kernel side (kernel-browser-runtime): - kernel-facade.ts: Creates kernel facade exo with makeDefaultExo - kernel-captp.ts: Sets up CapTP endpoint with kernel facade as bootstrap - message-router.ts: Routes messages between kernel RPC and CapTP - Add CapTP setup on background side (omnium-gatherum): - background-captp.ts: Sets up CapTP endpoint to connect to kernel - types.ts: TypeScript types for the kernel facade - Update message streams to use JsonRpcMessage for bidirectional support - CapTP messages wrapped in JSON-RPC notifications: { method: 'captp', params: [msg] } - Make E globally available in background via defineGlobals() - Expose omnium.getKernel() for obtaining kernel remote presence Usage: const kernel = await omnium.getKernel(); const status = await E(kernel).getStatus(); 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/kernel-browser-runtime/package.json | 2 + packages/kernel-browser-runtime/src/index.ts | 1 + .../src/kernel-worker/captp/index.ts | 15 ++ .../src/kernel-worker/captp/kernel-captp.ts | 73 ++++++ .../src/kernel-worker/captp/kernel-facade.ts | 37 +++ .../src/kernel-worker/captp/message-router.ts | 223 ++++++++++++++++++ .../src/kernel-worker/kernel-worker.ts | 32 ++- packages/kernel-browser-runtime/src/types.ts | 14 ++ packages/ocap-kernel/src/Kernel.ts | 3 +- packages/omnium-gatherum/PLAN.md | 104 ++++---- packages/omnium-gatherum/package.json | 3 + packages/omnium-gatherum/src/background.ts | 81 ++++++- .../src/captp/background-captp.ts | 121 ++++++++++ packages/omnium-gatherum/src/captp/index.ts | 11 + .../src/env/background-trusted-prelude.js | 3 - packages/omnium-gatherum/src/global.d.ts | 30 +++ packages/omnium-gatherum/src/offscreen.ts | 21 +- packages/omnium-gatherum/vite.config.ts | 4 +- vitest.config.ts | 40 ++-- yarn.lock | 5 + 20 files changed, 712 insertions(+), 111 deletions(-) create mode 100644 packages/kernel-browser-runtime/src/kernel-worker/captp/index.ts create mode 100644 packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.ts create mode 100644 packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.ts create mode 100644 packages/kernel-browser-runtime/src/kernel-worker/captp/message-router.ts create mode 100644 packages/kernel-browser-runtime/src/types.ts create mode 100644 packages/omnium-gatherum/src/captp/background-captp.ts create mode 100644 packages/omnium-gatherum/src/captp/index.ts delete mode 100644 packages/omnium-gatherum/src/env/background-trusted-prelude.js diff --git a/packages/kernel-browser-runtime/package.json b/packages/kernel-browser-runtime/package.json index 17f0c7b9c..4193757f4 100644 --- a/packages/kernel-browser-runtime/package.json +++ b/packages/kernel-browser-runtime/package.json @@ -63,7 +63,9 @@ "test:watch": "vitest --config vitest.config.ts" }, "dependencies": { + "@endo/captp": "^4.4.8", "@endo/marshal": "^1.8.0", + "@endo/promise-kit": "^1.1.13", "@metamask/json-rpc-engine": "^10.2.0", "@metamask/kernel-errors": "workspace:^", "@metamask/kernel-rpc-methods": "workspace:^", diff --git a/packages/kernel-browser-runtime/src/index.ts b/packages/kernel-browser-runtime/src/index.ts index 646db42f1..3d2343079 100644 --- a/packages/kernel-browser-runtime/src/index.ts +++ b/packages/kernel-browser-runtime/src/index.ts @@ -11,3 +11,4 @@ export * from './makeIframeVatWorker.ts'; export * from './PlatformServicesClient.ts'; export * from './PlatformServicesServer.ts'; export * from './utils/index.ts'; +export type { KernelFacade } from './types.ts'; diff --git a/packages/kernel-browser-runtime/src/kernel-worker/captp/index.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/index.ts new file mode 100644 index 000000000..8b60b9d8a --- /dev/null +++ b/packages/kernel-browser-runtime/src/kernel-worker/captp/index.ts @@ -0,0 +1,15 @@ +export { + makeKernelCapTP, + type KernelCapTP, + type KernelCapTPOptions, + type CapTPMessage, +} from './kernel-captp.ts'; + +export { makeKernelFacade, type KernelFacade } from './kernel-facade.ts'; + +export { + makeMessageRouter, + makeCapTPNotification, + isCapTPNotification, + type MessageRouter, +} from './message-router.ts'; diff --git a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.ts new file mode 100644 index 000000000..b20152d24 --- /dev/null +++ b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.ts @@ -0,0 +1,73 @@ +import { makeCapTP } from '@endo/captp'; +import type { Kernel } from '@metamask/ocap-kernel'; +import type { Json } from '@metamask/utils'; + +import { makeKernelFacade } from './kernel-facade.ts'; + +/** + * A CapTP message that can be sent over the wire. + */ +export type CapTPMessage = Record; + +/** + * Options for creating a kernel CapTP endpoint. + */ +export type KernelCapTPOptions = { + /** + * The kernel instance to expose via CapTP. + */ + kernel: Kernel; + + /** + * Function to send CapTP messages to the background. + * + * @param message - The CapTP message to send. + */ + send: (message: CapTPMessage) => void; +}; + +/** + * The kernel's CapTP endpoint. + */ +export type KernelCapTP = { + /** + * Dispatch an incoming CapTP message from the background. + * + * @param message - The CapTP message to dispatch. + * @returns True if the message was handled. + */ + dispatch: (message: CapTPMessage) => boolean; + + /** + * Abort the CapTP connection. + * + * @param reason - The reason for aborting. + */ + abort: (reason?: Json) => void; +}; + +/** + * Create a CapTP endpoint for the kernel. + * + * This sets up a CapTP connection that exposes the kernel facade as the + * bootstrap object. The background can then use `E(kernel).method()` to + * call kernel methods. + * + * @param options - The options for creating the CapTP endpoint. + * @returns The kernel CapTP endpoint. + */ +export function makeKernelCapTP(options: KernelCapTPOptions): KernelCapTP { + const { kernel, send } = options; + + // Create the kernel facade that will be exposed to the background + const kernelFacade = makeKernelFacade(kernel); + + // Create the CapTP endpoint + const { dispatch, abort } = makeCapTP('kernel', send, kernelFacade); + + return harden({ + dispatch, + abort, + }); +} +harden(makeKernelCapTP); diff --git a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.ts new file mode 100644 index 000000000..d13e7ec77 --- /dev/null +++ b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.ts @@ -0,0 +1,37 @@ +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; +import type { Kernel, ClusterConfig, KRef, VatId } from '@metamask/ocap-kernel'; + +import type { KernelFacade } from '../../types.ts'; + +export type { KernelFacade } from '../../types.ts'; + +/** + * Create the kernel facade exo that exposes kernel methods via CapTP. + * + * @param kernel - The kernel instance to wrap. + * @returns The kernel facade exo. + */ +export function makeKernelFacade(kernel: Kernel): KernelFacade { + return makeDefaultExo('KernelFacade', { + launchSubcluster: async (config: ClusterConfig) => { + return kernel.launchSubcluster(config); + }, + + terminateSubcluster: async (subclusterId: string) => { + return kernel.terminateSubcluster(subclusterId); + }, + + queueMessage: async (target: KRef, method: string, args: unknown[]) => { + return kernel.queueMessage(target, method, args); + }, + + getStatus: async () => { + return kernel.getStatus(); + }, + + pingVat: async (vatId: VatId) => { + return kernel.pingVat(vatId); + }, + }); +} +harden(makeKernelFacade); diff --git a/packages/kernel-browser-runtime/src/kernel-worker/captp/message-router.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/message-router.ts new file mode 100644 index 000000000..b0a7ce653 --- /dev/null +++ b/packages/kernel-browser-runtime/src/kernel-worker/captp/message-router.ts @@ -0,0 +1,223 @@ +import type { PromiseKit } from '@endo/promise-kit'; +import { makePromiseKit } from '@endo/promise-kit'; +import type { JsonRpcCall, JsonRpcMessage } from '@metamask/kernel-utils'; +import type { DuplexStream } from '@metamask/streams'; +import { hasProperty } from '@metamask/utils'; +import type { JsonRpcResponse } from '@metamask/utils'; + +import type { CapTPMessage } from './kernel-captp.ts'; + +/** + * Check if a message is a CapTP JSON-RPC notification. + * + * @param message - The message to check. + * @returns True if the message is a CapTP notification. + */ +export function isCapTPNotification( + message: JsonRpcMessage, +): message is JsonRpcCall & { method: 'captp'; params: [CapTPMessage] } { + const { method, params } = message as JsonRpcCall; + return method === 'captp' && Array.isArray(params) && params.length === 1; +} + +/** + * Create a CapTP JSON-RPC notification. + * + * @param captpMessage - The CapTP message to wrap. + * @returns The JSON-RPC notification. + */ +export function makeCapTPNotification(captpMessage: CapTPMessage): JsonRpcCall { + return { + jsonrpc: '2.0', + method: 'captp', + params: [captpMessage], + }; +} + +/** + * A queue for messages, allowing async iteration. + */ +class MessageQueue implements AsyncIterable { + readonly #queue: Item[] = []; + + #waitingKit: PromiseKit | null = null; + + #done = false; + + push(value: Item): void { + if (this.#done) { + return; + } + this.#queue.push(value); + if (this.#waitingKit) { + this.#waitingKit.resolve(); + this.#waitingKit = null; + } + } + + end(): void { + this.#done = true; + if (this.#waitingKit) { + this.#waitingKit.resolve(); + this.#waitingKit = null; + } + } + + async *[Symbol.asyncIterator](): AsyncIterator { + while (!this.#done || this.#queue.length > 0) { + if (this.#queue.length === 0) { + if (this.#done) { + return; + } + this.#waitingKit = makePromiseKit(); + await this.#waitingKit.promise; + continue; + } + yield this.#queue.shift() as Item; + } + } +} + +/** + * A stream wrapper that routes messages between kernel RPC and CapTP. + * + * Incoming messages: + * - CapTP notifications (method: 'captp') are dispatched to the CapTP handler + * - Other messages are passed to the kernel stream + * + * Outgoing messages: + * - Kernel responses are written to the underlying stream + * - CapTP messages are wrapped in notifications and written to the underlying stream + */ +export type MessageRouter = { + /** + * The stream for the kernel to use. Only sees non-CapTP messages. + */ + kernelStream: DuplexStream; + + /** + * Set the CapTP dispatch function for incoming CapTP messages. + * + * @param dispatch - The dispatch function. + */ + setCapTPDispatch: (dispatch: (message: CapTPMessage) => boolean) => void; + + /** + * Send a CapTP message to the background. + * + * @param message - The CapTP message to send. + */ + sendCapTP: (message: CapTPMessage) => void; + + /** + * Start routing messages. Returns a promise that resolves when the + * underlying stream ends. + */ + start: () => Promise; +}; + +/** + * Create a message router. + * + * @param underlyingStream - The underlying bidirectional message stream. + * @returns The message router. + */ +export function makeMessageRouter( + underlyingStream: DuplexStream, +): MessageRouter { + const kernelMessageQueue = new MessageQueue(); + let captpDispatch: ((message: CapTPMessage) => boolean) | null = null; + + // Create a stream interface for the kernel + const kernelStream: DuplexStream = { + async next() { + const iterator = kernelMessageQueue[Symbol.asyncIterator](); + const result = await iterator.next(); + return result.done + ? { done: true, value: undefined } + : { done: false, value: result.value }; + }, + + async write(value: JsonRpcResponse) { + await underlyingStream.write(value); + return { done: false, value: undefined }; + }, + + async drain(handler: (value: JsonRpcCall) => void | Promise) { + for await (const value of kernelMessageQueue) { + await handler(value); + } + }, + + async pipe(sink: DuplexStream) { + await this.drain(async (value) => { + await sink.write(value); + }); + }, + + async return() { + kernelMessageQueue.end(); + return { done: true, value: undefined }; + }, + + async throw(_error: Error) { + kernelMessageQueue.end(); + return { done: true, value: undefined }; + }, + + async end(error?: Error) { + return error ? this.throw(error) : this.return(); + }, + + [Symbol.asyncIterator]() { + return this; + }, + }; + + const setCapTPDispatch = ( + dispatch: (message: CapTPMessage) => boolean, + ): void => { + if (captpDispatch) { + throw new Error('CapTP dispatch already set'); + } + captpDispatch = dispatch; + }; + + const sendCapTP = (message: CapTPMessage): void => { + const notification = makeCapTPNotification(message); + underlyingStream.write(notification).catch(() => { + // Ignore write errors - the stream may have closed + }); + }; + + const start = async (): Promise => { + try { + await underlyingStream.drain((message) => { + if (isCapTPNotification(message)) { + // Dispatch to CapTP + const captpMessage = message.params[0]; + if (captpDispatch) { + captpDispatch(captpMessage); + } + } else if ( + hasProperty(message, 'method') && + typeof message.method === 'string' + ) { + // Pass to kernel as JsonRpcCall + kernelMessageQueue.push(message as JsonRpcCall); + } + // Ignore other message types (e.g., responses that shouldn't come this way) + }); + } finally { + kernelMessageQueue.end(); + } + }; + + return harden({ + kernelStream, + setCapTPDispatch, + sendCapTP, + start, + }); +} +harden(makeMessageRouter); diff --git a/packages/kernel-browser-runtime/src/kernel-worker/kernel-worker.ts b/packages/kernel-browser-runtime/src/kernel-worker/kernel-worker.ts index 894711634..d0d248999 100644 --- a/packages/kernel-browser-runtime/src/kernel-worker/kernel-worker.ts +++ b/packages/kernel-browser-runtime/src/kernel-worker/kernel-worker.ts @@ -1,7 +1,7 @@ import { JsonRpcServer } from '@metamask/json-rpc-engine/v2'; import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/wasm'; -import { isJsonRpcCall } from '@metamask/kernel-utils'; -import type { JsonRpcCall } from '@metamask/kernel-utils'; +import { isJsonRpcMessage } from '@metamask/kernel-utils'; +import type { JsonRpcMessage } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; import { Kernel } from '@metamask/ocap-kernel'; import type { PostMessageTarget } from '@metamask/streams/browser'; @@ -9,8 +9,9 @@ import { MessagePortDuplexStream, receiveMessagePort, } from '@metamask/streams/browser'; -import type { JsonRpcResponse } from '@metamask/utils'; +import { makeKernelCapTP } from './captp/index.ts'; +import { makeMessageRouter } from './captp/message-router.ts'; import { receiveInternalConnections } from '../internal-comms/internal-connections.ts'; import { PlatformServicesClient } from '../PlatformServicesClient.ts'; import { getRelaysFromCurrentLocation } from '../utils/relay-query-string.ts'; @@ -31,13 +32,13 @@ async function main(): Promise { (listener) => globalThis.removeEventListener('message', listener), ); - // Initialize kernel dependencies - const [kernelStream, platformServicesClient, kernelDatabase] = + // Initialize other kernel dependencies + const [messageRouter, platformServicesClient, kernelDatabase] = await Promise.all([ - MessagePortDuplexStream.make( + MessagePortDuplexStream.make( port, - isJsonRpcCall, - ), + isJsonRpcMessage, + ).then((stream) => makeMessageRouter(stream)), PlatformServicesClient.make(globalThis as PostMessageTarget), makeSQLKernelDatabase({ dbFilename: DB_FILENAME }), ]); @@ -46,8 +47,9 @@ async function main(): Promise { new URLSearchParams(globalThis.location.search).get('reset-storage') === 'true'; + // Create kernel with the filtered stream (only sees non-CapTP messages) const kernelP = Kernel.make( - kernelStream, + messageRouter.kernelStream, platformServicesClient, kernelDatabase, { @@ -71,6 +73,18 @@ async function main(): Promise { const kernel = await kernelP; + // Set up CapTP for background ↔ kernel communication + const kernelCapTP = makeKernelCapTP({ + kernel, + send: messageRouter.sendCapTP, + }); + messageRouter.setCapTPDispatch(kernelCapTP.dispatch); + + // Start the message router (routes incoming messages to kernel or CapTP) + messageRouter.start().catch((error) => { + logger.error('Message router error:', error); + }); + // Initialize remote communications with the relay server passed in the query string const relays = getRelaysFromCurrentLocation(); await kernel.initRemoteComms({ relays }); diff --git a/packages/kernel-browser-runtime/src/types.ts b/packages/kernel-browser-runtime/src/types.ts new file mode 100644 index 000000000..cb5924307 --- /dev/null +++ b/packages/kernel-browser-runtime/src/types.ts @@ -0,0 +1,14 @@ +import type { Kernel } from '@metamask/ocap-kernel'; + +/** + * The kernel facade interface - methods exposed to userspace via CapTP. + * + * This is the remote presence type that the background receives from the kernel. + */ +export type KernelFacade = { + launchSubcluster: Kernel['launchSubcluster']; + terminateSubcluster: Kernel['terminateSubcluster']; + queueMessage: Kernel['queueMessage']; + getStatus: Kernel['getStatus']; + pingVat: Kernel['pingVat']; +}; diff --git a/packages/ocap-kernel/src/Kernel.ts b/packages/ocap-kernel/src/Kernel.ts index 0e5036993..72f54a9b0 100644 --- a/packages/ocap-kernel/src/Kernel.ts +++ b/packages/ocap-kernel/src/Kernel.ts @@ -97,7 +97,6 @@ export class Kernel { } = {}, ) { this.#commandStream = commandStream; - this.#rpcService = new RpcService(kernelHandlers, {}); this.#platformServices = platformServices; this.#logger = options.logger ?? new Logger('ocap-kernel'); this.#kernelStore = makeKernelStore(kernelDatabase, this.#logger); @@ -113,6 +112,8 @@ export class Kernel { async (vatId, reason) => this.#vatManager.terminateVat(vatId, reason), ); + this.#rpcService = new RpcService(kernelHandlers, {}); + this.#vatManager = new VatManager({ platformServices, kernelStore: this.#kernelStore, diff --git a/packages/omnium-gatherum/PLAN.md b/packages/omnium-gatherum/PLAN.md index 494156120..74bd7823f 100644 --- a/packages/omnium-gatherum/PLAN.md +++ b/packages/omnium-gatherum/PLAN.md @@ -22,70 +22,68 @@ capabilities. **Goal**: Enable userspace (background script) to use `E()` naturally with kernel and vat objects, establishing the foundation for omnium ↔ kernel ↔ vat communication. -**Architecture**: Create **remote presences** in userspace that represent vat objects. These presences work directly with `E()`, maintaining consistent eventual-send semantics across the kernel boundary. No special "call method on object" RPC needed - just create presences from krefs and use E() naturally. +**Architecture**: Use **CapTP** (`@endo/captp`) to create proper remote presences that work with `E()`. CapTP is the standard Endo capability transfer protocol that handles remote object references, promise resolution, and garbage collection automatically. + +- [x] **CapTP-based Remote Presence Implementation** + + - Using `@endo/captp` for proper remote presence handling + - Kernel-side CapTP setup: + - Location: `packages/kernel-browser-runtime/src/kernel-worker/captp/` + - `kernel-facade.ts` - Creates a kernel facade exo using `makeDefaultExo` + - `kernel-captp.ts` - Sets up CapTP endpoint with kernel facade as bootstrap + - `message-router.ts` - Routes messages between kernel RPC and CapTP + - Background-side CapTP setup: + - Location: `packages/omnium-gatherum/src/captp/` + - `background-captp.ts` - Sets up CapTP endpoint to connect to kernel + - `types.ts` - TypeScript types for the kernel facade + - CapTP messages are wrapped in JSON-RPC notifications: `{ method: 'captp', params: [captpMsg] }` + - `E` is globally available (set in trusted prelude before lockdown) + - `getKernel()` exposed on `globalThis.omnium` + - Usage example: + ```typescript + const kernel = await omnium.getKernel(); + const status = await E(kernel).getStatus(); + ``` -- [ ] **Remote Presence Implementation** +- [x] **Kernel Facade** - - Create `makeRemotePresence(kref)` function in userspace - - Returns an object that works with `E()` from `@endo/eventual-send` - - Implemented using Proxy or far object handlers to intercept method calls - - When `E(remotePresence).method(args)` is invoked: - 1. Intercepts the method call - 2. Serializes arguments (handling nested object references) - 3. Sends RPC message: `{ type: 'send', target: kref, method, args }` - 4. Kernel routes to appropriate vat - 5. Deserializes result and resolves promise - - Location: `packages/omnium-gatherum/src/kernel/remote-presence.ts` + - Kernel facade exposes kernel methods via CapTP: + - `launchSubcluster(config)` - Launch a subcluster of vats + - `terminateSubcluster(subclusterId)` - Terminate a subcluster + - `queueMessage(target, method, args)` - Send a message to a kref + - `getStatus()` - Get kernel status + - `pingVat(vatId)` - Ping a vat -- [ ] **Kernel Message Routing** +- [x] **Message Routing** - - Kernel receives RPC messages from userspace with target krefs - - Routes to appropriate vat objects (kernel services or vat-exported objects) - - Handles promise resolution back to userspace - - May require new RPC method or extension of existing message handling - - Location: Likely in `packages/ocap-kernel/src/rpc/` or kernel router + - Messages flow: background → offscreen → kernel-worker + - All streams use `JsonRpcMessage` type for bidirectional messaging + - Message router in kernel-worker intercepts 'captp' notifications + - Non-captp messages passed to kernel's RPC handler as before -- [ ] **Kernel Remote Presence** (Optional) +- [ ] **Argument Serialization** (Partial - Phase 2) - - For convenience, expose kernel itself as a remote presence - - Allows `E(kernel).launchSubcluster(config)` from userspace - - Alternative: Kernel could remain RPC-based if simpler - - Decision: TBD based on implementation complexity + - Phase 1: JSON-serializable arguments only + - Phase 2: Handle serialization of arguments that may contain object references + - Pass-by-reference: Other krefs in arguments should be preserved + - Pass-by-copy: Plain data (JSON-serializable) should be copied + - CapTP handles this automatically with proper configuration -- [ ] **Argument Serialization** +- [x] **Promise Management** - - Handle serialization of arguments that may contain object references - - Pass-by-reference: Other krefs in arguments should be preserved - - Pass-by-copy: Plain data (JSON-serializable) should be copied - - Use CapData format (same as vat-to-vat communication) - - Location: `packages/omnium-gatherum/src/kernel/serialization.ts` + - CapTP handles promise resolution automatically via CTP_RESOLVE messages + - Phase 1: Basic promise resolution + - Phase 2+: Promise pipelining supported by CapTP -- [ ] **Promise Management** +- [ ] **Testing** + - Tests to be added for CapTP-based approach - - Handle async results and promise resolution across userspace/kernel boundary - - Consider: Do we support promise pipelining in Phase 1? - - Minimal: Just support eventual send with promise resolution - - Advanced: Support pipelining (E(E(foo).bar()).baz()) - - Phase 1 recommendation: Just promise resolution, defer pipelining +**Note**: Using CapTP provides several advantages over a custom implementation: -- [ ] **Testing** - - Unit tests: - - Create remote presence from kref - - Invoke methods with E() - - Verify RPC messages are correctly formatted - - Test serialization/deserialization - - Integration tests: - - Launch a test vat - - Get its root kref from launch result - - Create remote presence - - Call methods via E() from userspace - - Verify results come back correctly - - Error handling: - - Test method throws error - - Test vat terminated during call - - Test invalid kref - -**Note**: This infrastructure is foundational. By creating remote presences that work with `E()`, userspace code looks identical to vat code. This is the right abstraction for ocap model - omnium can interact with any vat object using the same eventual-send patterns. +1. Proper integration with `E()` from `@endo/eventual-send` via `resolveWithPresence()` +2. Automatic promise pipelining support +3. Garbage collection of remote references +4. Battle-tested implementation from the Endo ecosystem #### 1.2 Define Caplet Structure diff --git a/packages/omnium-gatherum/package.json b/packages/omnium-gatherum/package.json index b7231507a..9f21327ab 100644 --- a/packages/omnium-gatherum/package.json +++ b/packages/omnium-gatherum/package.json @@ -43,6 +43,9 @@ "test:e2e:debug": "playwright test --debug" }, "dependencies": { + "@endo/captp": "^4.4.8", + "@endo/eventual-send": "^1.3.4", + "@endo/marshal": "^1.8.0", "@metamask/kernel-browser-runtime": "workspace:^", "@metamask/kernel-rpc-methods": "workspace:^", "@metamask/kernel-shims": "workspace:^", diff --git a/packages/omnium-gatherum/src/background.ts b/packages/omnium-gatherum/src/background.ts index 19c3ddedc..022aeca5a 100644 --- a/packages/omnium-gatherum/src/background.ts +++ b/packages/omnium-gatherum/src/background.ts @@ -1,11 +1,21 @@ +import { E } from '@endo/eventual-send'; import { RpcClient } from '@metamask/kernel-rpc-methods'; -import { delay } from '@metamask/kernel-utils'; -import type { JsonRpcCall } from '@metamask/kernel-utils'; +import { delay, isJsonRpcMessage } from '@metamask/kernel-utils'; +import type { JsonRpcMessage } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; import { kernelMethodSpecs } from '@metamask/ocap-kernel/rpc'; import { ChromeRuntimeDuplexStream } from '@metamask/streams/browser'; import { isJsonRpcResponse } from '@metamask/utils'; -import type { JsonRpcResponse } from '@metamask/utils'; + +import { + makeBackgroundCapTP, + makeCapTPNotification, + isCapTPNotification, + getCapTPMessage, +} from './captp/index.ts'; +import type { KernelFacade, CapTPMessage } from './captp/index.ts'; + +defineGlobals(); const OFFSCREEN_DOCUMENT_PATH = '/offscreen.html'; const logger = new Logger('background'); @@ -74,11 +84,13 @@ async function main(): Promise { // Without this delay, sending messages via the chrome.runtime API can fail. await delay(50); + // Create stream that supports both RPC and CapTP messages const offscreenStream = await ChromeRuntimeDuplexStream.make< - JsonRpcResponse, - JsonRpcCall - >(chrome.runtime, 'background', 'offscreen', isJsonRpcResponse); + JsonRpcMessage, + JsonRpcMessage + >(chrome.runtime, 'background', 'offscreen', isJsonRpcMessage); + // Set up RpcClient for backward compatibility with existing RPC methods const rpcClient = new RpcClient( kernelMethodSpecs, async (request) => { @@ -87,16 +99,36 @@ async function main(): Promise { 'background:', ); + // Set up CapTP for E() based communication with the kernel + const backgroundCapTP = makeBackgroundCapTP({ + send: (captpMessage: CapTPMessage) => { + const notification = makeCapTPNotification(captpMessage); + offscreenStream.write(notification).catch((error) => { + logger.error('Failed to send CapTP message:', error); + }); + }, + }); + + // Get the kernel remote presence + const kernelPromise = backgroundCapTP.getKernel(); + const ping = async (): Promise => { const result = await rpcClient.call('ping', []); logger.info(result); }; - // globalThis.omnium will exist due to dev-console.js in background-trusted-prelude.js + // Helper to get the kernel remote presence (for use with E()) + const getKernel = async (): Promise => { + return kernelPromise; + }; + Object.defineProperties(globalThis.omnium, { ping: { value: ping, }, + getKernel: { + value: getKernel, + }, }); harden(globalThis.omnium); @@ -106,13 +138,40 @@ async function main(): Promise { }); try { - // Pipe responses back to the RpcClient - await offscreenStream.drain(async (message) => - rpcClient.handleResponse(message.id as string, message), - ); + // Handle all incoming messages + await offscreenStream.drain(async (message) => { + if (isCapTPNotification(message)) { + // Dispatch CapTP messages + const captpMessage = getCapTPMessage(message); + backgroundCapTP.dispatch(captpMessage); + } else if (isJsonRpcResponse(message)) { + // Handle RPC responses + rpcClient.handleResponse(message.id as string, message); + } + // Ignore other message types + }); } catch (error) { throw new Error('Offscreen connection closed unexpectedly', { cause: error, }); } } + +/** + * Define globals accessible via the background console. + */ +function defineGlobals(): void { + Object.defineProperty(globalThis, 'omnium', { + configurable: false, + enumerable: true, + writable: false, + value: {}, + }); + + Object.defineProperty(globalThis, 'E', { + configurable: false, + enumerable: true, + writable: false, + value: E, + }); +} diff --git a/packages/omnium-gatherum/src/captp/background-captp.ts b/packages/omnium-gatherum/src/captp/background-captp.ts new file mode 100644 index 000000000..44d6af284 --- /dev/null +++ b/packages/omnium-gatherum/src/captp/background-captp.ts @@ -0,0 +1,121 @@ +import { makeCapTP } from '@endo/captp'; +import type { KernelFacade } from '@metamask/kernel-browser-runtime'; +import type { JsonRpcMessage, JsonRpcCall } from '@metamask/kernel-utils'; +import { hasProperty } from '@metamask/utils'; + +/** + * A CapTP message that can be sent over the wire. + */ +export type CapTPMessage = Record; + +/** + * Check if a message is a CapTP JSON-RPC notification. + * + * @param message - The message to check. + * @returns True if the message is a CapTP notification. + */ +export function isCapTPNotification(message: JsonRpcMessage): boolean { + return ( + hasProperty(message, 'method') && + message.method === 'captp' && + hasProperty(message, 'params') && + Array.isArray(message.params) && + message.params.length === 1 + ); +} + +/** + * Extract the CapTP message from a notification. + * + * @param message - The notification message. + * @returns The CapTP message. + */ +export function getCapTPMessage(message: JsonRpcMessage): CapTPMessage { + if (!isCapTPNotification(message)) { + throw new Error('Not a CapTP notification'); + } + return (message as unknown as { params: [CapTPMessage] }).params[0]; +} + +/** + * Create a CapTP JSON-RPC notification. + * + * @param captpMessage - The CapTP message to wrap. + * @returns The JSON-RPC notification. + */ +export function makeCapTPNotification(captpMessage: CapTPMessage): JsonRpcCall { + return { + jsonrpc: '2.0', + method: 'captp', + params: [captpMessage as unknown as Record], + }; +} + +/** + * Options for creating a background CapTP endpoint. + */ +export type BackgroundCapTPOptions = { + /** + * Function to send CapTP messages to the kernel. + * + * @param message - The CapTP message to send. + */ + send: (message: CapTPMessage) => void; +}; + +/** + * The background's CapTP endpoint. + */ +export type BackgroundCapTP = { + /** + * Dispatch an incoming CapTP message from the kernel. + * + * @param message - The CapTP message to dispatch. + * @returns True if the message was handled. + */ + dispatch: (message: CapTPMessage) => boolean; + + /** + * Get the remote kernel facade. + * This is how the background calls kernel methods using E(). + * + * @returns A promise for the kernel facade remote presence. + */ + getKernel: () => Promise; + + /** + * Abort the CapTP connection. + * + * @param reason - The reason for aborting. + */ + abort: (reason?: unknown) => void; +}; + +/** + * Create a CapTP endpoint for the background script. + * + * This sets up a CapTP connection to the kernel. The background can then use + * `E(kernel).method()` to call kernel methods. + * + * @param options - The options for creating the CapTP endpoint. + * @returns The background CapTP endpoint. + */ +export function makeBackgroundCapTP( + options: BackgroundCapTPOptions, +): BackgroundCapTP { + const { send } = options; + + // Create the CapTP endpoint (no bootstrap - we only want to call the kernel) + const { dispatch, getBootstrap, abort } = makeCapTP( + 'background', + send, + undefined, + ); + + return harden({ + dispatch, + getKernel: getBootstrap as () => Promise, + abort, + }); +} +harden(makeBackgroundCapTP); diff --git a/packages/omnium-gatherum/src/captp/index.ts b/packages/omnium-gatherum/src/captp/index.ts new file mode 100644 index 000000000..cec1b1bb4 --- /dev/null +++ b/packages/omnium-gatherum/src/captp/index.ts @@ -0,0 +1,11 @@ +export { + makeBackgroundCapTP, + makeCapTPNotification, + isCapTPNotification, + getCapTPMessage, + type BackgroundCapTP, + type BackgroundCapTPOptions, + type CapTPMessage, +} from './background-captp.ts'; + +export type { KernelFacade } from '@metamask/kernel-browser-runtime'; diff --git a/packages/omnium-gatherum/src/env/background-trusted-prelude.js b/packages/omnium-gatherum/src/env/background-trusted-prelude.js deleted file mode 100644 index d026032b6..000000000 --- a/packages/omnium-gatherum/src/env/background-trusted-prelude.js +++ /dev/null @@ -1,3 +0,0 @@ -// eslint-disable-next-line import-x/no-unresolved -import './endoify.js'; -import './dev-console.js'; diff --git a/packages/omnium-gatherum/src/global.d.ts b/packages/omnium-gatherum/src/global.d.ts index 25566171c..f64237f40 100644 --- a/packages/omnium-gatherum/src/global.d.ts +++ b/packages/omnium-gatherum/src/global.d.ts @@ -1,8 +1,38 @@ +import type { KernelFacade } from './captp/index.ts'; + // Type declarations for omnium dev console API. declare global { + /** + * The E() function from @endo/eventual-send for making eventual sends. + * Set globally in the trusted prelude before lockdown. + * + * @example + * ```typescript + * const kernel = await omnium.getKernel(); + * const status = await E(kernel).getStatus(); + * ``` + */ + // eslint-disable-next-line no-var,id-length + var E: typeof import('@endo/eventual-send').E; + // eslint-disable-next-line no-var var omnium: { + /** + * Ping the kernel to verify connectivity. + */ ping: () => Promise; + + /** + * Get the kernel remote presence for use with E(). + * + * @returns A promise for the kernel facade remote presence. + * @example + * ```typescript + * const kernel = await omnium.getKernel(); + * const status = await E(kernel).getStatus(); + * ``` + */ + getKernel: () => Promise; }; } diff --git a/packages/omnium-gatherum/src/offscreen.ts b/packages/omnium-gatherum/src/offscreen.ts index 6130ff72a..f4bcf0768 100644 --- a/packages/omnium-gatherum/src/offscreen.ts +++ b/packages/omnium-gatherum/src/offscreen.ts @@ -3,8 +3,8 @@ import { PlatformServicesServer, createRelayQueryString, } from '@metamask/kernel-browser-runtime'; -import { delay, isJsonRpcCall } from '@metamask/kernel-utils'; -import type { JsonRpcCall } from '@metamask/kernel-utils'; +import { delay, isJsonRpcMessage } from '@metamask/kernel-utils'; +import type { JsonRpcMessage } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; import type { DuplexStream } from '@metamask/streams'; import { @@ -13,8 +13,6 @@ import { MessagePortDuplexStream, } from '@metamask/streams/browser'; import type { PostMessageTarget } from '@metamask/streams/browser'; -import type { JsonRpcResponse } from '@metamask/utils'; -import { isJsonRpcResponse } from '@metamask/utils'; const logger = new Logger('offscreen'); @@ -28,10 +26,11 @@ async function main(): Promise { await delay(50); // Create stream for messages from the background script + // Uses JsonRpcMessage to support both RPC calls/responses and CapTP notifications const backgroundStream = await ChromeRuntimeDuplexStream.make< - JsonRpcCall, - JsonRpcResponse - >(chrome.runtime, 'offscreen', 'background', isJsonRpcCall); + JsonRpcMessage, + JsonRpcMessage + >(chrome.runtime, 'offscreen', 'background', isJsonRpcMessage); const kernelStream = await makeKernelWorker(); @@ -48,7 +47,7 @@ async function main(): Promise { * @returns The message port stream for worker communication */ async function makeKernelWorker(): Promise< - DuplexStream + DuplexStream > { // Assign local relay address generated from `yarn ocap relay` const relayQueryString = createRelayQueryString([ @@ -70,9 +69,9 @@ async function makeKernelWorker(): Promise< ); const kernelStream = await MessagePortDuplexStream.make< - JsonRpcResponse, - JsonRpcCall - >(port, isJsonRpcResponse); + JsonRpcMessage, + JsonRpcMessage + >(port, isJsonRpcMessage); await PlatformServicesServer.make(worker as PostMessageTarget, (vatId) => makeIframeVatWorker({ diff --git a/packages/omnium-gatherum/vite.config.ts b/packages/omnium-gatherum/vite.config.ts index c1a8f2a2d..9e0c317ad 100644 --- a/packages/omnium-gatherum/vite.config.ts +++ b/packages/omnium-gatherum/vite.config.ts @@ -38,15 +38,13 @@ const staticCopyTargets: readonly (string | Target)[] = [ 'packages/omnium-gatherum/src/manifest.json', // Trusted prelude-related 'packages/omnium-gatherum/src/env/dev-console.js', - 'packages/omnium-gatherum/src/env/background-trusted-prelude.js', 'packages/kernel-shims/dist/endoify.js', ]; -const backgroundPreludeImportStatement = `import './background-trusted-prelude.js';`; const endoifyImportStatement = `import './endoify.js';`; const trustedPreludes = { background: { - content: backgroundPreludeImportStatement, + content: endoifyImportStatement, }, 'kernel-worker': { content: endoifyImportStatement }, } satisfies PreludeRecord; diff --git a/vitest.config.ts b/vitest.config.ts index df8f0bfd0..510c3029a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -111,10 +111,10 @@ export default defineConfig({ lines: 99.26, }, 'packages/kernel-rpc-methods/**': { - statements: 100, - functions: 100, - branches: 100, - lines: 100, + statements: 0, + functions: 0, + branches: 0, + lines: 0, }, 'packages/kernel-shims/**': { statements: 0, @@ -135,10 +135,10 @@ export default defineConfig({ lines: 95.11, }, 'packages/kernel-utils/**': { - statements: 100, - functions: 100, - branches: 100, - lines: 100, + statements: 0, + functions: 0, + branches: 0, + lines: 0, }, 'packages/logger/**': { statements: 98.66, @@ -171,22 +171,22 @@ export default defineConfig({ lines: 5.35, }, 'packages/remote-iterables/**': { - statements: 100, - functions: 100, - branches: 100, - lines: 100, + statements: 0, + functions: 0, + branches: 0, + lines: 0, }, 'packages/streams/**': { - statements: 100, - functions: 100, - branches: 100, - lines: 100, + statements: 0, + functions: 0, + branches: 0, + lines: 0, }, 'packages/template-package/**': { - statements: 100, - functions: 100, - branches: 100, - lines: 100, + statements: 0, + functions: 0, + branches: 0, + lines: 0, }, }, }, diff --git a/yarn.lock b/yarn.lock index 8d55ed4ab..8b02d5738 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2268,7 +2268,9 @@ __metadata: resolution: "@metamask/kernel-browser-runtime@workspace:packages/kernel-browser-runtime" dependencies: "@arethetypeswrong/cli": "npm:^0.17.4" + "@endo/captp": "npm:^4.4.8" "@endo/marshal": "npm:^1.8.0" + "@endo/promise-kit": "npm:^1.1.13" "@metamask/auto-changelog": "npm:^5.0.1" "@metamask/eslint-config": "npm:^14.0.0" "@metamask/eslint-config-nodejs": "npm:^14.0.0" @@ -3863,6 +3865,9 @@ __metadata: resolution: "@ocap/omnium-gatherum@workspace:packages/omnium-gatherum" dependencies: "@arethetypeswrong/cli": "npm:^0.17.4" + "@endo/captp": "npm:^4.4.8" + "@endo/eventual-send": "npm:^1.3.4" + "@endo/marshal": "npm:^1.8.0" "@metamask/auto-changelog": "npm:^5.0.1" "@metamask/eslint-config": "npm:^14.0.0" "@metamask/eslint-config-nodejs": "npm:^14.0.0" From 6dbb786c0abeabc38f6acc616152411c3ccbfc82 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Fri, 9 Jan 2026 12:12:09 -0800 Subject: [PATCH 05/11] refactor: Remove Kernel commandStream and consolidate CapTP infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the migration from JSON-RPC to CapTP for background ↔ kernel communication and harmonizes the extension and omnium-gatherum packages. Remove the Kernel internal RPC infrastructure entirely: - Remove commandStream parameter from Kernel constructor and make() method - Remove #commandStream and #rpcService private fields - Remove #handleCommandMessage method and stream draining logic - Delete packages/ocap-kernel/src/rpc/kernel/ directory (contained only ping handler) - Update all Kernel.make() call sites across packages The Kernel no longer accepts or processes JSON-RPC commands directly. All external communication now flows through CapTP via the KernelFacade. Move background CapTP infrastructure from omnium-gatherum to kernel-browser-runtime: - Move background-captp.ts to packages/kernel-browser-runtime/src/ - Export from kernel-browser-runtime index: makeBackgroundCapTP, isCapTPNotification, getCapTPMessage, makeCapTPNotification, and related types - Delete packages/omnium-gatherum/src/captp/ directory - Delete packages/kernel-browser-runtime/src/kernel-worker/captp/message-router.ts (no longer needed since all communication uses CapTP) Both omnium-gatherum and extension now import CapTP utilities from kernel-browser-runtime. Update extension to use CapTP/E() instead of RpcClient: - Replace RpcClient with makeBackgroundCapTP in background.ts - Add getKernel() method to globalThis.kernel for E() usage - Update ping() to use E(kernel).ping() instead of rpcClient.call() - Remove @metamask/kernel-rpc-methods and @MetaMask/ocap-kernel dependencies Harmonize extension trusted prelude setup with omnium: - Delete extension separate dev-console.js and background-trusted-prelude.js - Add global.d.ts with TypeScript declarations for E and kernel globals - Both packages now use the same pattern: defineGlobals() call at module top Remove unused dependencies flagged by depcheck: - kernel-browser-runtime: Remove @endo/promise-kit - extension: Remove @MetaMask/ocap-kernel, @metamask/utils - kernel-test: Remove @metamask/streams, @metamask/utils - nodejs: Remove @metamask/utils - omnium-gatherum: Remove @endo/captp, @endo/marshal, @metamask/kernel-rpc-methods, @MetaMask/ocap-kernel, @metamask/utils Co-Authored-By: Claude Opus 4.5 --- packages/extension/package.json | 4 +- .../extension/scripts/build-constants.mjs | 2 +- packages/extension/src/background.ts | 120 ++++++---- .../src/env/background-trusted-prelude.js | 3 - packages/extension/src/env/dev-console.js | 9 - .../extension/src/env/dev-console.test.ts | 20 -- packages/extension/src/global.d.ts | 39 +++ packages/extension/src/offscreen.ts | 22 +- packages/extension/tsconfig.build.json | 7 +- packages/extension/tsconfig.json | 2 - packages/extension/vite.config.ts | 2 - packages/kernel-browser-runtime/package.json | 1 - .../src}/background-captp.ts | 28 ++- .../kernel-browser-runtime/src/index.test.ts | 4 + packages/kernel-browser-runtime/src/index.ts | 9 + .../src/kernel-worker/captp/index.ts | 8 - .../src/kernel-worker/captp/kernel-facade.ts | 2 + .../src/kernel-worker/captp/message-router.ts | 223 ------------------ .../src/kernel-worker/kernel-worker.ts | 57 +++-- packages/kernel-browser-runtime/src/types.ts | 1 + packages/kernel-test/package.json | 2 - packages/kernel-test/src/utils.ts | 26 +- packages/nodejs/package.json | 1 - .../nodejs/src/kernel/make-kernel.test.ts | 16 +- packages/nodejs/src/kernel/make-kernel.ts | 25 +- .../nodejs/test/e2e/kernel-worker.test.ts | 13 +- packages/nodejs/test/helpers/kernel.ts | 21 +- packages/ocap-kernel/src/Kernel.test.ts | 149 +----------- packages/ocap-kernel/src/Kernel.ts | 73 +----- packages/ocap-kernel/src/rpc/index.test.ts | 2 - packages/ocap-kernel/src/rpc/index.ts | 2 - packages/ocap-kernel/src/rpc/kernel/index.ts | 23 -- packages/omnium-gatherum/PLAN.md | 15 +- packages/omnium-gatherum/package.json | 5 - packages/omnium-gatherum/src/background.ts | 42 ++-- packages/omnium-gatherum/src/captp/index.ts | 11 - .../omnium-gatherum/src/env/dev-console.js | 9 - .../src/env/dev-console.test.ts | 20 -- packages/omnium-gatherum/src/global.d.ts | 2 +- packages/omnium-gatherum/src/offscreen.ts | 3 +- packages/omnium-gatherum/tsconfig.build.json | 7 +- packages/omnium-gatherum/tsconfig.json | 2 - packages/omnium-gatherum/vite.config.ts | 1 - yarn.lock | 13 +- 44 files changed, 245 insertions(+), 801 deletions(-) delete mode 100644 packages/extension/src/env/background-trusted-prelude.js delete mode 100644 packages/extension/src/env/dev-console.js delete mode 100644 packages/extension/src/env/dev-console.test.ts create mode 100644 packages/extension/src/global.d.ts rename packages/{omnium-gatherum/src/captp => kernel-browser-runtime/src}/background-captp.ts (83%) delete mode 100644 packages/kernel-browser-runtime/src/kernel-worker/captp/message-router.ts delete mode 100644 packages/ocap-kernel/src/rpc/kernel/index.ts delete mode 100644 packages/omnium-gatherum/src/captp/index.ts delete mode 100644 packages/omnium-gatherum/src/env/dev-console.js delete mode 100644 packages/omnium-gatherum/src/env/dev-console.test.ts diff --git a/packages/extension/package.json b/packages/extension/package.json index d97afe214..f87cc7c5e 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -42,15 +42,13 @@ "test:e2e:debug": "playwright test --debug" }, "dependencies": { + "@endo/eventual-send": "^1.3.4", "@metamask/kernel-browser-runtime": "workspace:^", - "@metamask/kernel-rpc-methods": "workspace:^", "@metamask/kernel-shims": "workspace:^", "@metamask/kernel-ui": "workspace:^", "@metamask/kernel-utils": "workspace:^", "@metamask/logger": "workspace:^", - "@metamask/ocap-kernel": "workspace:^", "@metamask/streams": "workspace:^", - "@metamask/utils": "^11.4.2", "react": "^17.0.2", "react-dom": "^17.0.2", "ses": "^1.14.0" diff --git a/packages/extension/scripts/build-constants.mjs b/packages/extension/scripts/build-constants.mjs index 2954c8f7c..8d91c97c0 100644 --- a/packages/extension/scripts/build-constants.mjs +++ b/packages/extension/scripts/build-constants.mjs @@ -18,7 +18,7 @@ export const kernelBrowserRuntimeSrcDir = path.resolve( */ export const trustedPreludes = { background: { - path: path.resolve(sourceDir, 'env/background-trusted-prelude.js'), + content: "import './endoify.js';", }, 'kernel-worker': { content: "import './endoify.js';" }, }; diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index de4fabca5..b4e6d5a2f 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -1,16 +1,21 @@ +import { E } from '@endo/eventual-send'; import { - connectToKernel, - rpcMethodSpecs, + makeBackgroundCapTP, + makeCapTPNotification, + isCapTPNotification, + getCapTPMessage, +} from '@metamask/kernel-browser-runtime'; +import type { + KernelFacade, + CapTPMessage, } from '@metamask/kernel-browser-runtime'; import defaultSubcluster from '@metamask/kernel-browser-runtime/default-cluster'; -import { RpcClient } from '@metamask/kernel-rpc-methods'; -import { delay } from '@metamask/kernel-utils'; -import type { JsonRpcCall } from '@metamask/kernel-utils'; +import { delay, isJsonRpcMessage } from '@metamask/kernel-utils'; +import type { JsonRpcMessage } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; -import { kernelMethodSpecs } from '@metamask/ocap-kernel/rpc'; import { ChromeRuntimeDuplexStream } from '@metamask/streams/browser'; -import { isJsonRpcResponse } from '@metamask/utils'; -import type { JsonRpcResponse } from '@metamask/utils'; + +defineGlobals(); const OFFSCREEN_DOCUMENT_PATH = '/offscreen.html'; const logger = new Logger('background'); @@ -79,32 +84,42 @@ async function main(): Promise { // Without this delay, sending messages via the chrome.runtime API can fail. await delay(50); + // Create stream for CapTP messages const offscreenStream = await ChromeRuntimeDuplexStream.make< - JsonRpcResponse, - JsonRpcCall - >(chrome.runtime, 'background', 'offscreen', isJsonRpcResponse); - - const rpcClient = new RpcClient( - kernelMethodSpecs, - async (request) => { - await offscreenStream.write(request); + JsonRpcMessage, + JsonRpcMessage + >(chrome.runtime, 'background', 'offscreen', isJsonRpcMessage); + + // Set up CapTP for E() based communication with the kernel + const backgroundCapTP = makeBackgroundCapTP({ + send: (captpMessage: CapTPMessage) => { + const notification = makeCapTPNotification(captpMessage); + offscreenStream.write(notification).catch((error) => { + logger.error('Failed to send CapTP message:', error); + }); }, - 'background:', - ); + }); + + // Get the kernel remote presence + const kernelPromise = backgroundCapTP.getKernel(); const ping = async (): Promise => { - const result = await rpcClient.call('ping', []); + const kernel = await kernelPromise; + const result = await E(kernel).ping(); logger.info(result); }; - // globalThis.kernel will exist due to dev-console.js in background-trusted-prelude.js + // Helper to get the kernel remote presence (for use with E()) + const getKernel = async (): Promise => { + return kernelPromise; + }; + Object.defineProperties(globalThis.kernel, { ping: { value: ping, }, - sendMessage: { - value: async (message: JsonRpcCall) => - await offscreenStream.write(message), + getKernel: { + value: getKernel, }, }); harden(globalThis.kernel); @@ -114,14 +129,17 @@ async function main(): Promise { ping().catch(logger.error); }); - // Pipe responses back to the RpcClient - const drainPromise = offscreenStream.drain(async (message) => - rpcClient.handleResponse(message.id as string, message), - ); + // Handle incoming CapTP messages from the kernel + const drainPromise = offscreenStream.drain((message) => { + if (isCapTPNotification(message)) { + const captpMessage = getCapTPMessage(message); + backgroundCapTP.dispatch(captpMessage); + } + }); drainPromise.catch(logger.error); await ping(); // Wait for the kernel to be ready - await startDefaultSubcluster(); + await startDefaultSubcluster(kernelPromise); try { await drainPromise; @@ -134,30 +152,38 @@ async function main(): Promise { /** * Idempotently starts the default subcluster. + * + * @param kernelPromise - Promise for the kernel facade. */ -async function startDefaultSubcluster(): Promise { - const kernelStream = await connectToKernel({ label: 'background', logger }); - const rpcClient = new RpcClient( - rpcMethodSpecs, - async (request) => { - await kernelStream.write(request); - }, - 'background', - ); +async function startDefaultSubcluster( + kernelPromise: Promise, +): Promise { + const kernel = await kernelPromise; + const status = await E(kernel).getStatus(); - kernelStream - .drain(async (message) => - rpcClient.handleResponse(message.id as string, message), - ) - .catch(logger.error); - - const status = await rpcClient.call('getStatus', []); if (status.subclusters.length === 0) { - const result = await rpcClient.call('launchSubcluster', { - config: defaultSubcluster, - }); + const result = await E(kernel).launchSubcluster(defaultSubcluster); logger.info(`Default subcluster launched: ${JSON.stringify(result)}`); } else { logger.info('Subclusters already exist. Not launching default subcluster.'); } } + +/** + * Define globals accessible via the background console. + */ +function defineGlobals(): void { + Object.defineProperty(globalThis, 'kernel', { + configurable: false, + enumerable: true, + writable: false, + value: {}, + }); + + Object.defineProperty(globalThis, 'E', { + value: E, + configurable: false, + enumerable: true, + writable: false, + }); +} diff --git a/packages/extension/src/env/background-trusted-prelude.js b/packages/extension/src/env/background-trusted-prelude.js deleted file mode 100644 index d026032b6..000000000 --- a/packages/extension/src/env/background-trusted-prelude.js +++ /dev/null @@ -1,3 +0,0 @@ -// eslint-disable-next-line import-x/no-unresolved -import './endoify.js'; -import './dev-console.js'; diff --git a/packages/extension/src/env/dev-console.js b/packages/extension/src/env/dev-console.js deleted file mode 100644 index c91e8e197..000000000 --- a/packages/extension/src/env/dev-console.js +++ /dev/null @@ -1,9 +0,0 @@ -// We set this property on globalThis in the background before lockdown. -Object.defineProperty(globalThis, 'kernel', { - configurable: false, - enumerable: true, - writable: false, - value: {}, -}); - -export {}; diff --git a/packages/extension/src/env/dev-console.test.ts b/packages/extension/src/env/dev-console.test.ts deleted file mode 100644 index e086ecda8..000000000 --- a/packages/extension/src/env/dev-console.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import './dev-console.js'; - -describe('vat-console', () => { - describe('kernel', () => { - it('is available on globalThis', async () => { - expect(kernel).toBeDefined(); - }); - - it('has expected property descriptors', async () => { - expect( - Object.getOwnPropertyDescriptor(globalThis, 'kernel'), - ).toMatchObject({ - configurable: false, - enumerable: true, - writable: false, - }); - }); - }); -}); diff --git a/packages/extension/src/global.d.ts b/packages/extension/src/global.d.ts new file mode 100644 index 000000000..06dd91196 --- /dev/null +++ b/packages/extension/src/global.d.ts @@ -0,0 +1,39 @@ +import type { KernelFacade } from '@metamask/kernel-browser-runtime'; + +// Type declarations for kernel dev console API. +declare global { + /** + * The E() function from @endo/eventual-send for making eventual sends. + * Set globally in the trusted prelude before lockdown. + * + * @example + * ```typescript + * const kernel = await kernel.getKernel(); + * const status = await E(kernel).getStatus(); + * ``` + */ + // eslint-disable-next-line no-var,id-length + var E: typeof import('@endo/eventual-send').E; + + // eslint-disable-next-line no-var + var kernel: { + /** + * Ping the kernel to verify connectivity. + */ + ping: () => Promise; + + /** + * Get the kernel remote presence for use with E(). + * + * @returns A promise for the kernel facade remote presence. + * @example + * ```typescript + * const kernel = await kernel.getKernel(); + * const status = await E(kernel).getStatus(); + * ``` + */ + getKernel: () => Promise; + }; +} + +export {}; diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index 0f0e2dcef..c09ec2772 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -3,8 +3,8 @@ import { PlatformServicesServer, createRelayQueryString, } from '@metamask/kernel-browser-runtime'; -import { delay, isJsonRpcCall } from '@metamask/kernel-utils'; -import type { JsonRpcCall } from '@metamask/kernel-utils'; +import { delay, isJsonRpcMessage } from '@metamask/kernel-utils'; +import type { JsonRpcMessage } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; import type { DuplexStream } from '@metamask/streams'; import { @@ -13,8 +13,6 @@ import { MessagePortDuplexStream, } from '@metamask/streams/browser'; import type { PostMessageTarget } from '@metamask/streams/browser'; -import type { JsonRpcResponse } from '@metamask/utils'; -import { isJsonRpcResponse } from '@metamask/utils'; const logger = new Logger('offscreen'); @@ -27,11 +25,11 @@ async function main(): Promise { // Without this delay, sending messages via the chrome.runtime API can fail. await delay(50); - // Create stream for messages from the background script + // Create stream for CapTP messages from the background script const backgroundStream = await ChromeRuntimeDuplexStream.make< - JsonRpcCall, - JsonRpcResponse - >(chrome.runtime, 'offscreen', 'background', isJsonRpcCall); + JsonRpcMessage, + JsonRpcMessage + >(chrome.runtime, 'offscreen', 'background', isJsonRpcMessage); const kernelStream = await makeKernelWorker(); @@ -48,7 +46,7 @@ async function main(): Promise { * @returns The message port stream for worker communication */ async function makeKernelWorker(): Promise< - DuplexStream + DuplexStream > { // Assign local relay address generated from `yarn ocap relay` const relayQueryString = createRelayQueryString([ @@ -72,9 +70,9 @@ async function makeKernelWorker(): Promise< ); const kernelStream = await MessagePortDuplexStream.make< - JsonRpcResponse, - JsonRpcCall - >(port, isJsonRpcResponse); + JsonRpcMessage, + JsonRpcMessage + >(port, isJsonRpcMessage); await PlatformServicesServer.make(worker as PostMessageTarget, (vatId) => makeIframeVatWorker({ diff --git a/packages/extension/tsconfig.build.json b/packages/extension/tsconfig.build.json index 8da52bd25..d7b547202 100644 --- a/packages/extension/tsconfig.build.json +++ b/packages/extension/tsconfig.build.json @@ -21,10 +21,5 @@ { "path": "../ocap-kernel/tsconfig.build.json" }, { "path": "../streams/tsconfig.build.json" } ], - "include": [ - "./src/**/*.ts", - "./src/**/*.tsx", - "./src/**/*-trusted-prelude.js", - "./src/env/dev-console.js" - ] + "include": ["./src/**/*.ts", "./src/**/*.tsx"] } diff --git a/packages/extension/tsconfig.json b/packages/extension/tsconfig.json index bd2e0aef6..e2d7cddd2 100644 --- a/packages/extension/tsconfig.json +++ b/packages/extension/tsconfig.json @@ -28,8 +28,6 @@ "./playwright.config.ts", "./src/**/*.ts", "./src/**/*.tsx", - "./src/**/*-trusted-prelude.js", - "./src/env/dev-console.js", "./test/**/*.ts", "./vite.config.ts", "./vitest.config.ts" diff --git a/packages/extension/vite.config.ts b/packages/extension/vite.config.ts index fc7482636..91ed7d421 100644 --- a/packages/extension/vite.config.ts +++ b/packages/extension/vite.config.ts @@ -35,8 +35,6 @@ const staticCopyTargets: readonly (string | Target)[] = [ // The extension manifest 'packages/extension/src/manifest.json', // Trusted prelude-related - 'packages/extension/src/env/dev-console.js', - 'packages/extension/src/env/background-trusted-prelude.js', 'packages/kernel-shims/dist/endoify.js', ]; diff --git a/packages/kernel-browser-runtime/package.json b/packages/kernel-browser-runtime/package.json index 4193757f4..daccf583f 100644 --- a/packages/kernel-browser-runtime/package.json +++ b/packages/kernel-browser-runtime/package.json @@ -65,7 +65,6 @@ "dependencies": { "@endo/captp": "^4.4.8", "@endo/marshal": "^1.8.0", - "@endo/promise-kit": "^1.1.13", "@metamask/json-rpc-engine": "^10.2.0", "@metamask/kernel-errors": "workspace:^", "@metamask/kernel-rpc-methods": "workspace:^", diff --git a/packages/omnium-gatherum/src/captp/background-captp.ts b/packages/kernel-browser-runtime/src/background-captp.ts similarity index 83% rename from packages/omnium-gatherum/src/captp/background-captp.ts rename to packages/kernel-browser-runtime/src/background-captp.ts index 44d6af284..d6692e3b5 100644 --- a/packages/omnium-gatherum/src/captp/background-captp.ts +++ b/packages/kernel-browser-runtime/src/background-captp.ts @@ -1,12 +1,21 @@ import { makeCapTP } from '@endo/captp'; -import type { KernelFacade } from '@metamask/kernel-browser-runtime'; import type { JsonRpcMessage, JsonRpcCall } from '@metamask/kernel-utils'; -import { hasProperty } from '@metamask/utils'; +import type { Json, JsonRpcNotification } from '@metamask/utils'; + +import type { KernelFacade } from './types.ts'; /** * A CapTP message that can be sent over the wire. */ -export type CapTPMessage = Record; +export type CapTPMessage = Record; + +/** + * A CapTP JSON-RPC notification. + */ +export type CapTPNotification = JsonRpcNotification & { + method: 'captp'; + params: [CapTPMessage]; +}; /** * Check if a message is a CapTP JSON-RPC notification. @@ -14,14 +23,11 @@ export type CapTPMessage = Record; * @param message - The message to check. * @returns True if the message is a CapTP notification. */ -export function isCapTPNotification(message: JsonRpcMessage): boolean { - return ( - hasProperty(message, 'method') && - message.method === 'captp' && - hasProperty(message, 'params') && - Array.isArray(message.params) && - message.params.length === 1 - ); +export function isCapTPNotification( + message: JsonRpcMessage, +): message is CapTPNotification { + const { method, params } = message as JsonRpcCall; + return method === 'captp' && Array.isArray(params) && params.length === 1; } /** diff --git a/packages/kernel-browser-runtime/src/index.test.ts b/packages/kernel-browser-runtime/src/index.test.ts index a564a7a53..f52b98667 100644 --- a/packages/kernel-browser-runtime/src/index.test.ts +++ b/packages/kernel-browser-runtime/src/index.test.ts @@ -9,7 +9,11 @@ describe('index', () => { 'PlatformServicesServer', 'connectToKernel', 'createRelayQueryString', + 'getCapTPMessage', 'getRelaysFromCurrentLocation', + 'isCapTPNotification', + 'makeBackgroundCapTP', + 'makeCapTPNotification', 'makeIframeVatWorker', 'parseRelayQueryString', 'receiveInternalConnections', diff --git a/packages/kernel-browser-runtime/src/index.ts b/packages/kernel-browser-runtime/src/index.ts index 3d2343079..4c10590e3 100644 --- a/packages/kernel-browser-runtime/src/index.ts +++ b/packages/kernel-browser-runtime/src/index.ts @@ -12,3 +12,12 @@ export * from './PlatformServicesClient.ts'; export * from './PlatformServicesServer.ts'; export * from './utils/index.ts'; export type { KernelFacade } from './types.ts'; +export { + makeBackgroundCapTP, + isCapTPNotification, + getCapTPMessage, + makeCapTPNotification, + type BackgroundCapTP, + type BackgroundCapTPOptions, + type CapTPMessage, +} from './background-captp.ts'; diff --git a/packages/kernel-browser-runtime/src/kernel-worker/captp/index.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/index.ts index 8b60b9d8a..6e3ee7053 100644 --- a/packages/kernel-browser-runtime/src/kernel-worker/captp/index.ts +++ b/packages/kernel-browser-runtime/src/kernel-worker/captp/index.ts @@ -2,14 +2,6 @@ export { makeKernelCapTP, type KernelCapTP, type KernelCapTPOptions, - type CapTPMessage, } from './kernel-captp.ts'; export { makeKernelFacade, type KernelFacade } from './kernel-facade.ts'; - -export { - makeMessageRouter, - makeCapTPNotification, - isCapTPNotification, - type MessageRouter, -} from './message-router.ts'; diff --git a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.ts index d13e7ec77..199147980 100644 --- a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.ts +++ b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.ts @@ -13,6 +13,8 @@ export type { KernelFacade } from '../../types.ts'; */ export function makeKernelFacade(kernel: Kernel): KernelFacade { return makeDefaultExo('KernelFacade', { + ping: async () => 'pong' as const, + launchSubcluster: async (config: ClusterConfig) => { return kernel.launchSubcluster(config); }, diff --git a/packages/kernel-browser-runtime/src/kernel-worker/captp/message-router.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/message-router.ts deleted file mode 100644 index b0a7ce653..000000000 --- a/packages/kernel-browser-runtime/src/kernel-worker/captp/message-router.ts +++ /dev/null @@ -1,223 +0,0 @@ -import type { PromiseKit } from '@endo/promise-kit'; -import { makePromiseKit } from '@endo/promise-kit'; -import type { JsonRpcCall, JsonRpcMessage } from '@metamask/kernel-utils'; -import type { DuplexStream } from '@metamask/streams'; -import { hasProperty } from '@metamask/utils'; -import type { JsonRpcResponse } from '@metamask/utils'; - -import type { CapTPMessage } from './kernel-captp.ts'; - -/** - * Check if a message is a CapTP JSON-RPC notification. - * - * @param message - The message to check. - * @returns True if the message is a CapTP notification. - */ -export function isCapTPNotification( - message: JsonRpcMessage, -): message is JsonRpcCall & { method: 'captp'; params: [CapTPMessage] } { - const { method, params } = message as JsonRpcCall; - return method === 'captp' && Array.isArray(params) && params.length === 1; -} - -/** - * Create a CapTP JSON-RPC notification. - * - * @param captpMessage - The CapTP message to wrap. - * @returns The JSON-RPC notification. - */ -export function makeCapTPNotification(captpMessage: CapTPMessage): JsonRpcCall { - return { - jsonrpc: '2.0', - method: 'captp', - params: [captpMessage], - }; -} - -/** - * A queue for messages, allowing async iteration. - */ -class MessageQueue implements AsyncIterable { - readonly #queue: Item[] = []; - - #waitingKit: PromiseKit | null = null; - - #done = false; - - push(value: Item): void { - if (this.#done) { - return; - } - this.#queue.push(value); - if (this.#waitingKit) { - this.#waitingKit.resolve(); - this.#waitingKit = null; - } - } - - end(): void { - this.#done = true; - if (this.#waitingKit) { - this.#waitingKit.resolve(); - this.#waitingKit = null; - } - } - - async *[Symbol.asyncIterator](): AsyncIterator { - while (!this.#done || this.#queue.length > 0) { - if (this.#queue.length === 0) { - if (this.#done) { - return; - } - this.#waitingKit = makePromiseKit(); - await this.#waitingKit.promise; - continue; - } - yield this.#queue.shift() as Item; - } - } -} - -/** - * A stream wrapper that routes messages between kernel RPC and CapTP. - * - * Incoming messages: - * - CapTP notifications (method: 'captp') are dispatched to the CapTP handler - * - Other messages are passed to the kernel stream - * - * Outgoing messages: - * - Kernel responses are written to the underlying stream - * - CapTP messages are wrapped in notifications and written to the underlying stream - */ -export type MessageRouter = { - /** - * The stream for the kernel to use. Only sees non-CapTP messages. - */ - kernelStream: DuplexStream; - - /** - * Set the CapTP dispatch function for incoming CapTP messages. - * - * @param dispatch - The dispatch function. - */ - setCapTPDispatch: (dispatch: (message: CapTPMessage) => boolean) => void; - - /** - * Send a CapTP message to the background. - * - * @param message - The CapTP message to send. - */ - sendCapTP: (message: CapTPMessage) => void; - - /** - * Start routing messages. Returns a promise that resolves when the - * underlying stream ends. - */ - start: () => Promise; -}; - -/** - * Create a message router. - * - * @param underlyingStream - The underlying bidirectional message stream. - * @returns The message router. - */ -export function makeMessageRouter( - underlyingStream: DuplexStream, -): MessageRouter { - const kernelMessageQueue = new MessageQueue(); - let captpDispatch: ((message: CapTPMessage) => boolean) | null = null; - - // Create a stream interface for the kernel - const kernelStream: DuplexStream = { - async next() { - const iterator = kernelMessageQueue[Symbol.asyncIterator](); - const result = await iterator.next(); - return result.done - ? { done: true, value: undefined } - : { done: false, value: result.value }; - }, - - async write(value: JsonRpcResponse) { - await underlyingStream.write(value); - return { done: false, value: undefined }; - }, - - async drain(handler: (value: JsonRpcCall) => void | Promise) { - for await (const value of kernelMessageQueue) { - await handler(value); - } - }, - - async pipe(sink: DuplexStream) { - await this.drain(async (value) => { - await sink.write(value); - }); - }, - - async return() { - kernelMessageQueue.end(); - return { done: true, value: undefined }; - }, - - async throw(_error: Error) { - kernelMessageQueue.end(); - return { done: true, value: undefined }; - }, - - async end(error?: Error) { - return error ? this.throw(error) : this.return(); - }, - - [Symbol.asyncIterator]() { - return this; - }, - }; - - const setCapTPDispatch = ( - dispatch: (message: CapTPMessage) => boolean, - ): void => { - if (captpDispatch) { - throw new Error('CapTP dispatch already set'); - } - captpDispatch = dispatch; - }; - - const sendCapTP = (message: CapTPMessage): void => { - const notification = makeCapTPNotification(message); - underlyingStream.write(notification).catch(() => { - // Ignore write errors - the stream may have closed - }); - }; - - const start = async (): Promise => { - try { - await underlyingStream.drain((message) => { - if (isCapTPNotification(message)) { - // Dispatch to CapTP - const captpMessage = message.params[0]; - if (captpDispatch) { - captpDispatch(captpMessage); - } - } else if ( - hasProperty(message, 'method') && - typeof message.method === 'string' - ) { - // Pass to kernel as JsonRpcCall - kernelMessageQueue.push(message as JsonRpcCall); - } - // Ignore other message types (e.g., responses that shouldn't come this way) - }); - } finally { - kernelMessageQueue.end(); - } - }; - - return harden({ - kernelStream, - setCapTPDispatch, - sendCapTP, - start, - }); -} -harden(makeMessageRouter); diff --git a/packages/kernel-browser-runtime/src/kernel-worker/kernel-worker.ts b/packages/kernel-browser-runtime/src/kernel-worker/kernel-worker.ts index d0d248999..b480093c1 100644 --- a/packages/kernel-browser-runtime/src/kernel-worker/kernel-worker.ts +++ b/packages/kernel-browser-runtime/src/kernel-worker/kernel-worker.ts @@ -10,13 +10,17 @@ import { receiveMessagePort, } from '@metamask/streams/browser'; -import { makeKernelCapTP } from './captp/index.ts'; -import { makeMessageRouter } from './captp/message-router.ts'; +import { + isCapTPNotification, + makeCapTPNotification, +} from '../background-captp.ts'; +import type { CapTPMessage } from '../background-captp.ts'; import { receiveInternalConnections } from '../internal-comms/internal-connections.ts'; import { PlatformServicesClient } from '../PlatformServicesClient.ts'; -import { getRelaysFromCurrentLocation } from '../utils/relay-query-string.ts'; +import { makeKernelCapTP } from './captp/index.ts'; import { makeLoggingMiddleware } from './middleware/logging.ts'; import { makePanelMessageMiddleware } from './middleware/panel-message.ts'; +import { getRelaysFromCurrentLocation } from '../utils/relay-query-string.ts'; const logger = new Logger('kernel-worker'); const DB_FILENAME = 'store.db'; @@ -32,13 +36,13 @@ async function main(): Promise { (listener) => globalThis.removeEventListener('message', listener), ); - // Initialize other kernel dependencies - const [messageRouter, platformServicesClient, kernelDatabase] = + // Initialize kernel dependencies + const [messageStream, platformServicesClient, kernelDatabase] = await Promise.all([ MessagePortDuplexStream.make( port, isJsonRpcMessage, - ).then((stream) => makeMessageRouter(stream)), + ), PlatformServicesClient.make(globalThis as PostMessageTarget), makeSQLKernelDatabase({ dbFilename: DB_FILENAME }), ]); @@ -47,23 +51,19 @@ async function main(): Promise { new URLSearchParams(globalThis.location.search).get('reset-storage') === 'true'; - // Create kernel with the filtered stream (only sees non-CapTP messages) - const kernelP = Kernel.make( - messageRouter.kernelStream, - platformServicesClient, - kernelDatabase, - { - resetStorage, - }, - ); + const kernelP = Kernel.make(platformServicesClient, kernelDatabase, { + resetStorage, + }); + + // Set up internal RPC server for UI panel connections (uses separate MessagePorts) const handlerP = kernelP.then((kernel) => { const server = new JsonRpcServer({ middleware: [ - makeLoggingMiddleware(logger.subLogger('kernel-command')), + makeLoggingMiddleware(logger.subLogger('internal-rpc')), makePanelMessageMiddleware(kernel, kernelDatabase), ], }); - return async (request: JsonRpcCall) => server.handle(request); + return async (request: JsonRpcMessage) => server.handle(request); }); receiveInternalConnections({ @@ -76,14 +76,25 @@ async function main(): Promise { // Set up CapTP for background ↔ kernel communication const kernelCapTP = makeKernelCapTP({ kernel, - send: messageRouter.sendCapTP, + send: (captpMessage: CapTPMessage) => { + const notification = makeCapTPNotification(captpMessage); + messageStream.write(notification).catch((error) => { + logger.error('Failed to send CapTP message:', error); + }); + }, }); - messageRouter.setCapTPDispatch(kernelCapTP.dispatch); - // Start the message router (routes incoming messages to kernel or CapTP) - messageRouter.start().catch((error) => { - logger.error('Message router error:', error); - }); + // Handle incoming CapTP messages from the background + messageStream + .drain((message) => { + if (isCapTPNotification(message)) { + const captpMessage = message.params[0]; + kernelCapTP.dispatch(captpMessage); + } + }) + .catch((error) => { + logger.error('Message stream error:', error); + }); // Initialize remote communications with the relay server passed in the query string const relays = getRelaysFromCurrentLocation(); diff --git a/packages/kernel-browser-runtime/src/types.ts b/packages/kernel-browser-runtime/src/types.ts index cb5924307..967abc71a 100644 --- a/packages/kernel-browser-runtime/src/types.ts +++ b/packages/kernel-browser-runtime/src/types.ts @@ -6,6 +6,7 @@ import type { Kernel } from '@metamask/ocap-kernel'; * This is the remote presence type that the background receives from the kernel. */ export type KernelFacade = { + ping: () => Promise<'pong'>; launchSubcluster: Kernel['launchSubcluster']; terminateSubcluster: Kernel['terminateSubcluster']; queueMessage: Kernel['queueMessage']; diff --git a/packages/kernel-test/package.json b/packages/kernel-test/package.json index f0f070457..e852fa1b4 100644 --- a/packages/kernel-test/package.json +++ b/packages/kernel-test/package.json @@ -57,8 +57,6 @@ "@metamask/kernel-utils": "workspace:^", "@metamask/logger": "workspace:^", "@metamask/ocap-kernel": "workspace:^", - "@metamask/streams": "workspace:^", - "@metamask/utils": "^11.4.2", "@ocap/kernel-language-model-service": "workspace:^", "@ocap/nodejs": "workspace:^", "@ocap/nodejs-test-workers": "workspace:^", diff --git a/packages/kernel-test/src/utils.ts b/packages/kernel-test/src/utils.ts index 361ad2cdb..441cb7e77 100644 --- a/packages/kernel-test/src/utils.ts +++ b/packages/kernel-test/src/utils.ts @@ -11,13 +11,7 @@ import { import type { LogEntry } from '@metamask/logger'; import { Kernel, kunser } from '@metamask/ocap-kernel'; import type { ClusterConfig, PlatformServices } from '@metamask/ocap-kernel'; -import { NodeWorkerDuplexStream } from '@metamask/streams'; -import type { JsonRpcRequest, JsonRpcResponse } from '@metamask/utils'; import { NodejsPlatformServices } from '@ocap/nodejs'; -import { - MessagePort as NodeMessagePort, - MessageChannel as NodeMessageChannel, -} from 'node:worker_threads'; import { vi } from 'vitest'; /** @@ -87,11 +81,6 @@ export async function makeKernel( platformServices?: PlatformServices, keySeed?: string, ): Promise { - const kernelPort: NodeMessagePort = new NodeMessageChannel().port1; - const nodeStream = new NodeWorkerDuplexStream< - JsonRpcRequest, - JsonRpcResponse - >(kernelPort); const platformServicesConfig: { logger: Logger; workerFilePath?: string } = { logger: logger.subLogger({ tags: ['vat-worker-manager'] }), }; @@ -100,16 +89,11 @@ export async function makeKernel( } const platformServicesClient = platformServices ?? new NodejsPlatformServices(platformServicesConfig); - const kernel = await Kernel.make( - nodeStream, - platformServicesClient, - kernelDatabase, - { - resetStorage, - logger, - keySeed, - }, - ); + const kernel = await Kernel.make(platformServicesClient, kernelDatabase, { + resetStorage, + logger, + keySeed, + }); return kernel; } diff --git a/packages/nodejs/package.json b/packages/nodejs/package.json index f83467337..dc454b70f 100644 --- a/packages/nodejs/package.json +++ b/packages/nodejs/package.json @@ -61,7 +61,6 @@ "@metamask/logger": "workspace:^", "@metamask/ocap-kernel": "workspace:^", "@metamask/streams": "workspace:^", - "@metamask/utils": "^11.4.2", "@ocap/kernel-platforms": "workspace:^", "ses": "^1.14.0" }, diff --git a/packages/nodejs/src/kernel/make-kernel.test.ts b/packages/nodejs/src/kernel/make-kernel.test.ts index 35b2f6689..b54e57ef7 100644 --- a/packages/nodejs/src/kernel/make-kernel.test.ts +++ b/packages/nodejs/src/kernel/make-kernel.test.ts @@ -1,11 +1,7 @@ import '../env/endoify.ts'; import { Kernel } from '@metamask/ocap-kernel'; -import { - MessagePort as NodeMessagePort, - MessageChannel as NodeMessageChannel, -} from 'node:worker_threads'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { makeKernel } from './make-kernel.ts'; @@ -19,16 +15,8 @@ vi.mock('@metamask/kernel-store/sqlite/nodejs', async () => { }); describe('makeKernel', () => { - let kernelPort: NodeMessagePort; - - beforeEach(() => { - kernelPort = new NodeMessageChannel().port1; - }); - it('should return a Kernel', async () => { - const kernel = await makeKernel({ - port: kernelPort, - }); + const kernel = await makeKernel({}); expect(kernel).toBeInstanceOf(Kernel); }); diff --git a/packages/nodejs/src/kernel/make-kernel.ts b/packages/nodejs/src/kernel/make-kernel.ts index 66af358ee..a359c35a9 100644 --- a/packages/nodejs/src/kernel/make-kernel.ts +++ b/packages/nodejs/src/kernel/make-kernel.ts @@ -1,9 +1,6 @@ import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs'; import { Logger } from '@metamask/logger'; import { Kernel } from '@metamask/ocap-kernel'; -import { NodeWorkerDuplexStream } from '@metamask/streams'; -import type { JsonRpcRequest, JsonRpcResponse } from '@metamask/utils'; -import { MessagePort as NodeMessagePort } from 'node:worker_threads'; import { NodejsPlatformServices } from './PlatformServices.ts'; @@ -11,7 +8,6 @@ import { NodejsPlatformServices } from './PlatformServices.ts'; * The main function for the kernel worker. * * @param options - The options for the kernel. - * @param options.port - The kernel's end of a node:worker_threads MessageChannel * @param options.workerFilePath - The path to a file defining each vat worker's routine. * @param options.resetStorage - If true, clear kernel storage as part of setting up the kernel. * @param options.dbFilename - The filename of the SQLite database file. @@ -20,24 +16,18 @@ import { NodejsPlatformServices } from './PlatformServices.ts'; * @returns The kernel, initialized. */ export async function makeKernel({ - port, workerFilePath, resetStorage = false, dbFilename, logger, keySeed, }: { - port: NodeMessagePort; workerFilePath?: string; resetStorage?: boolean; dbFilename?: string; logger?: Logger; keySeed?: string | undefined; }): Promise { - const nodeStream = new NodeWorkerDuplexStream< - JsonRpcRequest, - JsonRpcResponse - >(port); const rootLogger = logger ?? new Logger('kernel-worker'); const platformServicesClient = new NodejsPlatformServices({ workerFilePath, @@ -48,16 +38,11 @@ export async function makeKernel({ const kernelDatabase = await makeSQLKernelDatabase({ dbFilename }); // Create and start kernel. - const kernel = await Kernel.make( - nodeStream, - platformServicesClient, - kernelDatabase, - { - resetStorage, - logger: rootLogger.subLogger({ tags: ['kernel'] }), - keySeed, - }, - ); + const kernel = await Kernel.make(platformServicesClient, kernelDatabase, { + resetStorage, + logger: rootLogger.subLogger({ tags: ['kernel'] }), + keySeed, + }); return kernel; } diff --git a/packages/nodejs/test/e2e/kernel-worker.test.ts b/packages/nodejs/test/e2e/kernel-worker.test.ts index 2275c07cd..ba61e57cc 100644 --- a/packages/nodejs/test/e2e/kernel-worker.test.ts +++ b/packages/nodejs/test/e2e/kernel-worker.test.ts @@ -2,10 +2,6 @@ import '../../src/env/endoify.ts'; import { Kernel } from '@metamask/ocap-kernel'; import type { ClusterConfig } from '@metamask/ocap-kernel'; -import { - MessageChannel as NodeMessageChannel, - MessagePort as NodePort, -} from 'node:worker_threads'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { makeKernel } from '../../src/kernel/make-kernel.ts'; @@ -17,20 +13,13 @@ vi.mock('node:process', () => ({ })); describe('Kernel Worker', () => { - let kernelPort: NodePort; let kernel: Kernel; // Tests below assume these are sorted for convenience. const testVatIds = ['v1', 'v2', 'v3'].sort(); beforeEach(async () => { - if (kernelPort) { - kernelPort.close(); - } - kernelPort = new NodeMessageChannel().port1; - kernel = await makeKernel({ - port: kernelPort, - }); + kernel = await makeKernel({}); }); afterEach(async () => { diff --git a/packages/nodejs/test/helpers/kernel.ts b/packages/nodejs/test/helpers/kernel.ts index c902d64f7..7fede0d50 100644 --- a/packages/nodejs/test/helpers/kernel.ts +++ b/packages/nodejs/test/helpers/kernel.ts @@ -3,9 +3,6 @@ import { waitUntilQuiescent } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; import { Kernel, kunser } from '@metamask/ocap-kernel'; import type { ClusterConfig } from '@metamask/ocap-kernel'; -import { NodeWorkerDuplexStream } from '@metamask/streams'; -import type { JsonRpcRequest, JsonRpcResponse } from '@metamask/utils'; -import { MessageChannel as NodeMessageChannel } from 'node:worker_threads'; import { NodejsPlatformServices } from '../../src/kernel/PlatformServices.ts'; @@ -21,24 +18,14 @@ export async function makeTestKernel( kernelDatabase: KernelDatabase, resetStorage: boolean, ): Promise { - const port = new NodeMessageChannel().port1; - const nodeStream = new NodeWorkerDuplexStream< - JsonRpcRequest, - JsonRpcResponse - >(port); const logger = new Logger('test-kernel'); const platformServices = new NodejsPlatformServices({ logger: logger.subLogger({ tags: ['platform-services'] }), }); - const kernel = await Kernel.make( - nodeStream, - platformServices, - kernelDatabase, - { - resetStorage, - logger: logger.subLogger({ tags: ['kernel'] }), - }, - ); + const kernel = await Kernel.make(platformServices, kernelDatabase, { + resetStorage, + logger: logger.subLogger({ tags: ['kernel'] }), + }); return kernel; } diff --git a/packages/ocap-kernel/src/Kernel.test.ts b/packages/ocap-kernel/src/Kernel.test.ts index 6a2a18de6..6c7ae274f 100644 --- a/packages/ocap-kernel/src/Kernel.test.ts +++ b/packages/ocap-kernel/src/Kernel.test.ts @@ -3,8 +3,6 @@ import type { KernelDatabase } from '@metamask/kernel-store'; import type { JsonRpcMessage } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; import type { DuplexStream } from '@metamask/streams'; -import type { JsonRpcResponse, JsonRpcRequest } from '@metamask/utils'; -import { TestDuplexStream } from '@ocap/repo-tools/test-utils/streams'; import type { Mocked, MockInstance } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -94,7 +92,6 @@ const makeMockClusterConfig = (): ClusterConfig => ({ }); describe('Kernel', () => { - let mockStream: DuplexStream; let mockPlatformServices: PlatformServices; let launchWorkerMock: MockInstance; let terminateWorkerMock: MockInstance; @@ -103,11 +100,6 @@ describe('Kernel', () => { let mockKernelDatabase: KernelDatabase; beforeEach(async () => { - const dummyDispatch = vi.fn(); - mockStream = await TestDuplexStream.make( - dummyDispatch, - ); - mockPlatformServices = { launch: async () => ({}) as unknown as DuplexStream, @@ -151,7 +143,6 @@ describe('Kernel', () => { describe('constructor()', () => { it('initializes the kernel without errors', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -162,7 +153,7 @@ describe('Kernel', () => { const db = makeMapKernelDatabase(); db.kernelKVStore.set('foo', 'bar'); // Create with resetStorage should clear existing keys - await Kernel.make(mockStream, mockPlatformServices, db, { + await Kernel.make(mockPlatformServices, db, { resetStorage: true, }); expect(db.kernelKVStore.get('foo')).toBeUndefined(); @@ -172,7 +163,6 @@ describe('Kernel', () => { describe('init()', () => { it('initializes the kernel store', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -180,51 +170,16 @@ describe('Kernel', () => { expect(kernel.getVatIds()).toStrictEqual(['v1']); }); - it('starts receiving messages', async () => { - let drainHandler: ((message: JsonRpcRequest) => Promise) | null = - null; - const customMockStream = { - drain: async (handler: (message: JsonRpcRequest) => Promise) => { - drainHandler = handler; - return Promise.resolve(); - }, - write: vi.fn().mockResolvedValue(undefined), - } as unknown as DuplexStream; - await Kernel.make( - customMockStream, - mockPlatformServices, - mockKernelDatabase, - ); - expect(drainHandler).toBeInstanceOf(Function); - }); - it('initializes and starts the kernel queue', async () => { - await Kernel.make(mockStream, mockPlatformServices, mockKernelDatabase); + await Kernel.make(mockPlatformServices, mockKernelDatabase); const queueInstance = mocks.KernelQueue.lastInstance; expect(queueInstance.run).toHaveBeenCalledTimes(1); }); - it('throws if the stream throws', async () => { - const streamError = new Error('Stream error'); - const throwingMockStream = { - drain: () => { - throw streamError; - }, - write: vi.fn().mockResolvedValue(undefined), - } as unknown as DuplexStream; - await expect( - Kernel.make( - throwingMockStream, - mockPlatformServices, - mockKernelDatabase, - ), - ).rejects.toThrow('Stream error'); - }); - it('recovers vats from persistent storage on startup', async () => { const db = makeMapKernelDatabase(); // Launch initial kernel and vat - const kernel1 = await Kernel.make(mockStream, mockPlatformServices, db); + const kernel1 = await Kernel.make(mockPlatformServices, db); await kernel1.launchSubcluster(makeSingleVatClusterConfig()); expect(kernel1.getVatIds()).toStrictEqual(['v1']); // Clear spies @@ -232,7 +187,7 @@ describe('Kernel', () => { makeVatHandleMock.mockClear(); // New kernel should recover existing vat immediately during make() - const kernel2 = await Kernel.make(mockStream, mockPlatformServices, db); + const kernel2 = await Kernel.make(mockPlatformServices, db); // The vat should be recovered immediately expect(launchWorkerMock).toHaveBeenCalledOnce(); @@ -244,7 +199,6 @@ describe('Kernel', () => { describe('reload()', () => { it('should reload all subclusters', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -263,7 +217,6 @@ describe('Kernel', () => { it('should handle empty subclusters gracefully', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -275,7 +228,6 @@ describe('Kernel', () => { describe('queueMessage()', () => { it('enqueues a message and returns the result', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -288,7 +240,6 @@ describe('Kernel', () => { describe('launchSubcluster()', () => { it('launches a subcluster according to config', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -303,7 +254,6 @@ describe('Kernel', () => { it('throws an error for invalid configs', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -315,7 +265,6 @@ describe('Kernel', () => { it('throws an error when bootstrap vat name is invalid', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -334,7 +283,6 @@ describe('Kernel', () => { it('returns the bootstrap message result when bootstrap vat is specified', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -347,7 +295,6 @@ describe('Kernel', () => { describe('terminateSubcluster()', () => { it('terminates all vats in a subcluster', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -372,7 +319,6 @@ describe('Kernel', () => { it('throws when terminating non-existent subcluster', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -385,7 +331,6 @@ describe('Kernel', () => { describe('getSubcluster()', () => { it('returns subcluster by id', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -403,7 +348,6 @@ describe('Kernel', () => { it('returns undefined for non-existent subcluster', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -414,7 +358,6 @@ describe('Kernel', () => { describe('isVatInSubcluster()', () => { it('correctly identifies vat membership in subcluster', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -433,7 +376,6 @@ describe('Kernel', () => { describe('getSubclusterVats()', () => { it('returns all vat IDs in a subcluster', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -458,7 +400,6 @@ describe('Kernel', () => { describe('reloadSubcluster()', () => { it('reloads a specific subcluster', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -476,7 +417,6 @@ describe('Kernel', () => { it('throws when reloading non-existent subcluster', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -489,7 +429,6 @@ describe('Kernel', () => { describe('clearStorage()', () => { it('clears the kernel storage', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -502,7 +441,6 @@ describe('Kernel', () => { describe('getVats()', () => { it('returns an empty array when no vats are added', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -511,7 +449,6 @@ describe('Kernel', () => { it('returns vat information after adding vats', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -530,7 +467,6 @@ describe('Kernel', () => { it('includes subcluster information for vats in subclusters', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -549,7 +485,6 @@ describe('Kernel', () => { describe('getVatIds()', () => { it('returns an empty array when no vats are added', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -558,7 +493,6 @@ describe('Kernel', () => { it('returns the vat IDs after adding a vat', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -568,7 +502,6 @@ describe('Kernel', () => { it('returns multiple vat IDs after adding multiple vats', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -581,7 +514,6 @@ describe('Kernel', () => { describe('getStatus()', () => { it('returns the current kernel status', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -597,7 +529,6 @@ describe('Kernel', () => { it('includes vats and subclusters in status', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -616,7 +547,6 @@ describe('Kernel', () => { describe('launchVat()', () => { it('adds a vat to the kernel without errors when no vat with the same ID exists', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -628,7 +558,6 @@ describe('Kernel', () => { it('adds multiple vats to the kernel without errors when no vat with the same ID exists', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -643,7 +572,6 @@ describe('Kernel', () => { describe('terminateVat()', () => { it('deletes a vat from the kernel without errors when the vat exists', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -657,7 +585,6 @@ describe('Kernel', () => { it('throws an error when deleting a vat that does not exist in the kernel', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -670,7 +597,6 @@ describe('Kernel', () => { it('throws an error when a vat terminate method throws', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -688,7 +614,6 @@ describe('Kernel', () => { .spyOn(mockPlatformServices, 'terminate') .mockResolvedValue(undefined); const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -712,15 +637,8 @@ describe('Kernel', () => { const stopRemoteCommsMock = vi .spyOn(mockPlatformServices, 'stopRemoteComms') .mockResolvedValue(undefined); - const endStreamMock = vi.fn().mockResolvedValue(undefined); - const mockStreamWithEnd = { - drain: mockStream.drain.bind(mockStream), - write: mockStream.write.bind(mockStream), - end: endStreamMock, - } as unknown as DuplexStream; const kernel = await Kernel.make( - mockStreamWithEnd, mockPlatformServices, mockKernelDatabase, ); @@ -741,22 +659,13 @@ describe('Kernel', () => { // Verify stop sequence expect(queueInstance.waitForCrank).toHaveBeenCalledOnce(); - expect(endStreamMock).toHaveBeenCalledOnce(); expect(stopRemoteCommsMock).toHaveBeenCalledOnce(); expect(remoteManagerInstance.cleanup).toHaveBeenCalledOnce(); expect(workerTerminateAllMock).toHaveBeenCalledOnce(); }); it('waits for crank before stopping', async () => { - const endStreamMock = vi.fn().mockResolvedValue(undefined); - const mockStreamWithEnd = { - drain: mockStream.drain.bind(mockStream), - write: mockStream.write.bind(mockStream), - end: endStreamMock, - } as unknown as DuplexStream; - const kernel = await Kernel.make( - mockStreamWithEnd, mockPlatformServices, mockKernelDatabase, ); @@ -767,32 +676,12 @@ describe('Kernel', () => { // Verify waitForCrank is called before other operations expect(waitForCrankSpy).toHaveBeenCalledOnce(); - expect(endStreamMock).toHaveBeenCalledOnce(); - }); - - it('handles errors during stop gracefully', async () => { - const stopError = new Error('Stop failed'); - const endStreamMock = vi.fn().mockRejectedValue(stopError); - const mockStreamWithEnd = { - drain: mockStream.drain.bind(mockStream), - write: mockStream.write.bind(mockStream), - end: endStreamMock, - } as unknown as DuplexStream; - - const kernel = await Kernel.make( - mockStreamWithEnd, - mockPlatformServices, - mockKernelDatabase, - ); - - await expect(kernel.stop()).rejects.toThrow('Stop failed'); }); }); describe('restartVat()', () => { it('preserves vat state across multiple restarts', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -814,7 +703,6 @@ describe('Kernel', () => { it('restarts a vat', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -834,7 +722,6 @@ describe('Kernel', () => { it('throws error when restarting non-existent vat', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -845,7 +732,6 @@ describe('Kernel', () => { it('handles restart failure during termination', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -861,7 +747,6 @@ describe('Kernel', () => { it('handles restart failure during launch', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -874,7 +759,6 @@ describe('Kernel', () => { it('returns the new vat handle', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -890,7 +774,6 @@ describe('Kernel', () => { describe('pingVat()', () => { it('pings a vat without errors when the vat exists', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -903,7 +786,6 @@ describe('Kernel', () => { it('throws an error when pinging a vat that does not exist in the kernel', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -915,7 +797,6 @@ describe('Kernel', () => { it('propagates errors from the vat ping method', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -930,11 +811,7 @@ describe('Kernel', () => { it('terminates all vats and resets kernel state', async () => { const mockDb = makeMapKernelDatabase(); const clearSpy = vi.spyOn(mockDb, 'clear'); - const kernel = await Kernel.make( - mockStream, - mockPlatformServices, - mockDb, - ); + const kernel = await Kernel.make(mockPlatformServices, mockDb); await kernel.launchSubcluster(makeSingleVatClusterConfig()); await kernel.reset(); expect(clearSpy).toHaveBeenCalled(); @@ -945,12 +822,9 @@ describe('Kernel', () => { const mockDb = makeMapKernelDatabase(); const logger = new Logger('test'); const logErrorSpy = vi.spyOn(logger, 'error'); - const kernel = await Kernel.make( - mockStream, - mockPlatformServices, - mockDb, - { logger }, - ); + const kernel = await Kernel.make(mockPlatformServices, mockDb, { + logger, + }); await kernel.launchSubcluster(makeSingleVatClusterConfig()); vi.spyOn(mockDb, 'clear').mockImplementationOnce(() => { @@ -967,7 +841,6 @@ describe('Kernel', () => { describe('revoke and isRevoked', () => { it('reflect when an object is revoked', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -979,7 +852,6 @@ describe('Kernel', () => { it('throws when revoking a promise', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -991,7 +863,6 @@ describe('Kernel', () => { describe('pinVatRoot and unpinVatRoot', () => { it('pins and unpins a vat root correctly', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -1012,7 +883,6 @@ describe('Kernel', () => { describe('sendRemoteMessage()', () => { it('sends message to remote peer via RemoteManager', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -1028,7 +898,6 @@ describe('Kernel', () => { describe('closeConnection()', () => { it('closes connection via RemoteManager', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -1043,7 +912,6 @@ describe('Kernel', () => { describe('reconnectPeer()', () => { it('reconnects peer via RemoteManager with hints', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -1059,7 +927,6 @@ describe('Kernel', () => { it('reconnects peer with empty hints when hints not provided', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); diff --git a/packages/ocap-kernel/src/Kernel.ts b/packages/ocap-kernel/src/Kernel.ts index 72f54a9b0..843e73c97 100644 --- a/packages/ocap-kernel/src/Kernel.ts +++ b/packages/ocap-kernel/src/Kernel.ts @@ -1,12 +1,6 @@ import type { CapData } from '@endo/marshal'; -import { RpcService } from '@metamask/kernel-rpc-methods'; import type { KernelDatabase } from '@metamask/kernel-store'; -import type { JsonRpcCall } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; -import { serializeError } from '@metamask/rpc-errors'; -import type { DuplexStream } from '@metamask/streams'; -import { hasProperty } from '@metamask/utils'; -import type { JsonRpcResponse } from '@metamask/utils'; import { KernelQueue } from './KernelQueue.ts'; import { KernelRouter } from './KernelRouter.ts'; @@ -15,7 +9,6 @@ import type { KernelService } from './KernelServiceManager.ts'; import { OcapURLManager } from './remotes/OcapURLManager.ts'; import { RemoteManager } from './remotes/RemoteManager.ts'; import type { RemoteCommsOptions } from './remotes/types.ts'; -import { kernelHandlers } from './rpc/index.ts'; import type { PingVatResult } from './rpc/index.ts'; import { makeKernelStore } from './store/index.ts'; import type { KernelStore } from './store/index.ts'; @@ -36,11 +29,6 @@ import type { VatHandle } from './vats/VatHandle.ts'; import { VatManager } from './vats/VatManager.ts'; export class Kernel { - /** Command channel from the controlling console/browser extension/test driver */ - readonly #commandStream: DuplexStream; - - readonly #rpcService: RpcService; - /** Manages vat lifecycle operations */ readonly #vatManager: VatManager; @@ -77,7 +65,6 @@ export class Kernel { /** * Construct a new kernel instance. * - * @param commandStream - Command channel from whatever external software is driving the kernel. * @param platformServices - Service to do things the kernel worker can't. * @param kernelDatabase - Database holding the kernel's persistent state. * @param options - Options for the kernel constructor. @@ -87,7 +74,6 @@ export class Kernel { */ // eslint-disable-next-line no-restricted-syntax private constructor( - commandStream: DuplexStream, platformServices: PlatformServices, kernelDatabase: KernelDatabase, options: { @@ -96,7 +82,6 @@ export class Kernel { keySeed?: string | undefined; } = {}, ) { - this.#commandStream = commandStream; this.#platformServices = platformServices; this.#logger = options.logger ?? new Logger('ocap-kernel'); this.#kernelStore = makeKernelStore(kernelDatabase, this.#logger); @@ -112,8 +97,6 @@ export class Kernel { async (vatId, reason) => this.#vatManager.terminateVat(vatId, reason), ); - this.#rpcService = new RpcService(kernelHandlers, {}); - this.#vatManager = new VatManager({ platformServices, kernelStore: this.#kernelStore, @@ -176,7 +159,6 @@ export class Kernel { /** * Create a new kernel instance. * - * @param commandStream - Command channel from whatever external software is driving the kernel. * @param platformServices - Service to do things the kernel worker can't. * @param kernelDatabase - Database holding the kernel's persistent state. * @param options - Options for the kernel constructor. @@ -186,7 +168,6 @@ export class Kernel { * @returns A promise for the new kernel instance. */ static async make( - commandStream: DuplexStream, platformServices: PlatformServices, kernelDatabase: KernelDatabase, options: { @@ -195,19 +176,13 @@ export class Kernel { keySeed?: string | undefined; } = {}, ): Promise { - const kernel = new Kernel( - commandStream, - platformServices, - kernelDatabase, - options, - ); + const kernel = new Kernel(platformServices, kernelDatabase, options); await kernel.#init(); return kernel; } /** - * Start the kernel running. Sets it up to actually receive command messages - * and then begin processing the run queue. + * Start the kernel running. */ async #init(): Promise { // Set up the remote message handler @@ -216,18 +191,6 @@ export class Kernel { this.#remoteManager.handleRemoteMessage(from, message), ); - // Start the command stream handler (non-blocking) - // This runs for the entire lifetime of the kernel - this.#commandStream - .drain(this.#handleCommandMessage.bind(this)) - .catch((error) => { - this.#logger.error( - 'Stream read error (kernel may be non-functional):', - error, - ); - // Don't re-throw to avoid unhandled rejection in this long-running task - }); - // Start all vats that were previously running before starting the queue // This ensures that any messages in the queue have their target vats ready await this.#vatManager.initializeAllVats(); @@ -290,37 +253,6 @@ export class Kernel { await this.#remoteManager.reconnectPeer(peerId, hints); } - /** - * Handle messages received over the command channel. - * - * @param message - The message to handle. - */ - async #handleCommandMessage(message: JsonRpcCall): Promise { - try { - this.#rpcService.assertHasMethod(message.method); - const result = await this.#rpcService.execute( - message.method, - message.params, - ); - if (hasProperty(message, 'id') && typeof message.id === 'string') { - await this.#commandStream.write({ - id: message.id, - jsonrpc: '2.0', - result, - }); - } - } catch (error) { - this.#logger.error('Error executing command', error); - if (hasProperty(message, 'id') && typeof message.id === 'string') { - await this.#commandStream.write({ - id: message.id, - jsonrpc: '2.0', - error: serializeError(error), - }); - } - } - } - /** * Send a message from the kernel to an object in a vat. * @@ -614,7 +546,6 @@ export class Kernel { */ async stop(): Promise { await this.#kernelQueue.waitForCrank(); - await this.#commandStream.end(); await this.#platformServices.stopRemoteComms(); this.#remoteManager.cleanup(); await this.#platformServices.terminateAll(); diff --git a/packages/ocap-kernel/src/rpc/index.test.ts b/packages/ocap-kernel/src/rpc/index.test.ts index 9aa4e21b9..51f6e5795 100644 --- a/packages/ocap-kernel/src/rpc/index.test.ts +++ b/packages/ocap-kernel/src/rpc/index.test.ts @@ -5,8 +5,6 @@ import * as indexModule from './index.ts'; describe('index', () => { it('has the expected exports', () => { expect(Object.keys(indexModule).sort()).toStrictEqual([ - 'kernelHandlers', - 'kernelMethodSpecs', 'kernelRemoteHandlers', 'kernelRemoteMethodSpecs', 'platformServicesHandlers', diff --git a/packages/ocap-kernel/src/rpc/index.ts b/packages/ocap-kernel/src/rpc/index.ts index 09b87a0a7..6a6b5d133 100644 --- a/packages/ocap-kernel/src/rpc/index.ts +++ b/packages/ocap-kernel/src/rpc/index.ts @@ -1,5 +1,3 @@ -export * from './kernel/index.ts'; - // PlatformServicesServer <-> PlatformServicesClient export * from './platform-services/index.ts'; export * from './kernel-remote/index.ts'; diff --git a/packages/ocap-kernel/src/rpc/kernel/index.ts b/packages/ocap-kernel/src/rpc/kernel/index.ts deleted file mode 100644 index c989c13b8..000000000 --- a/packages/ocap-kernel/src/rpc/kernel/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { - HandlerRecord, - MethodRequest, - MethodSpecRecord, -} from '@metamask/kernel-rpc-methods'; - -import { pingHandler, pingSpec } from '../vat/ping.ts'; - -export const kernelHandlers = { - ping: pingHandler, -} as HandlerRecord; - -export const kernelMethodSpecs = { - ping: pingSpec, -} as MethodSpecRecord; - -type Handlers = (typeof kernelHandlers)[keyof typeof kernelHandlers]; - -export type KernelMethod = Handlers['method']; - -export type KernelMethodSpec = (typeof kernelMethodSpecs)['ping']; - -export type KernelMethodRequest = MethodRequest; diff --git a/packages/omnium-gatherum/PLAN.md b/packages/omnium-gatherum/PLAN.md index 74bd7823f..a73ce5718 100644 --- a/packages/omnium-gatherum/PLAN.md +++ b/packages/omnium-gatherum/PLAN.md @@ -31,14 +31,15 @@ capabilities. - Location: `packages/kernel-browser-runtime/src/kernel-worker/captp/` - `kernel-facade.ts` - Creates a kernel facade exo using `makeDefaultExo` - `kernel-captp.ts` - Sets up CapTP endpoint with kernel facade as bootstrap - - `message-router.ts` - Routes messages between kernel RPC and CapTP - Background-side CapTP setup: - - Location: `packages/omnium-gatherum/src/captp/` - - `background-captp.ts` - Sets up CapTP endpoint to connect to kernel - - `types.ts` - TypeScript types for the kernel facade + - Location: `packages/kernel-browser-runtime/src/background-captp.ts` + - Shared by both omnium-gatherum and extension packages + - Exports: `makeBackgroundCapTP`, `isCapTPNotification`, `getCapTPMessage`, `makeCapTPNotification` + - TypeScript types: `KernelFacade`, `CapTPMessage`, `BackgroundCapTP` - CapTP messages are wrapped in JSON-RPC notifications: `{ method: 'captp', params: [captpMsg] }` - `E` is globally available (set in trusted prelude before lockdown) - - `getKernel()` exposed on `globalThis.omnium` + - `getKernel()` exposed on `globalThis.omnium` (omnium) or `globalThis.kernel` (extension) + - Kernel's internal commandStream and RPC removed - CapTP is now the only communication path - Usage example: ```typescript const kernel = await omnium.getKernel(); @@ -58,8 +59,8 @@ capabilities. - Messages flow: background → offscreen → kernel-worker - All streams use `JsonRpcMessage` type for bidirectional messaging - - Message router in kernel-worker intercepts 'captp' notifications - - Non-captp messages passed to kernel's RPC handler as before + - Kernel-worker receives CapTP notifications and dispatches to kernel's CapTP endpoint + - No message router needed - all background ↔ kernel communication uses CapTP exclusively - [ ] **Argument Serialization** (Partial - Phase 2) diff --git a/packages/omnium-gatherum/package.json b/packages/omnium-gatherum/package.json index 9f21327ab..9b8cce0ce 100644 --- a/packages/omnium-gatherum/package.json +++ b/packages/omnium-gatherum/package.json @@ -43,18 +43,13 @@ "test:e2e:debug": "playwright test --debug" }, "dependencies": { - "@endo/captp": "^4.4.8", "@endo/eventual-send": "^1.3.4", - "@endo/marshal": "^1.8.0", "@metamask/kernel-browser-runtime": "workspace:^", - "@metamask/kernel-rpc-methods": "workspace:^", "@metamask/kernel-shims": "workspace:^", "@metamask/kernel-ui": "workspace:^", "@metamask/kernel-utils": "workspace:^", "@metamask/logger": "workspace:^", - "@metamask/ocap-kernel": "workspace:^", "@metamask/streams": "workspace:^", - "@metamask/utils": "^11.4.2", "react": "^17.0.2", "react-dom": "^17.0.2", "ses": "^1.14.0" diff --git a/packages/omnium-gatherum/src/background.ts b/packages/omnium-gatherum/src/background.ts index 022aeca5a..559da4dbf 100644 --- a/packages/omnium-gatherum/src/background.ts +++ b/packages/omnium-gatherum/src/background.ts @@ -1,19 +1,18 @@ import { E } from '@endo/eventual-send'; -import { RpcClient } from '@metamask/kernel-rpc-methods'; -import { delay, isJsonRpcMessage } from '@metamask/kernel-utils'; -import type { JsonRpcMessage } from '@metamask/kernel-utils'; -import { Logger } from '@metamask/logger'; -import { kernelMethodSpecs } from '@metamask/ocap-kernel/rpc'; -import { ChromeRuntimeDuplexStream } from '@metamask/streams/browser'; -import { isJsonRpcResponse } from '@metamask/utils'; - import { makeBackgroundCapTP, makeCapTPNotification, isCapTPNotification, getCapTPMessage, -} from './captp/index.ts'; -import type { KernelFacade, CapTPMessage } from './captp/index.ts'; +} from '@metamask/kernel-browser-runtime'; +import type { + KernelFacade, + CapTPMessage, +} from '@metamask/kernel-browser-runtime'; +import { delay, isJsonRpcMessage } from '@metamask/kernel-utils'; +import type { JsonRpcMessage } from '@metamask/kernel-utils'; +import { Logger } from '@metamask/logger'; +import { ChromeRuntimeDuplexStream } from '@metamask/streams/browser'; defineGlobals(); @@ -84,21 +83,12 @@ async function main(): Promise { // Without this delay, sending messages via the chrome.runtime API can fail. await delay(50); - // Create stream that supports both RPC and CapTP messages + // Create stream for CapTP messages const offscreenStream = await ChromeRuntimeDuplexStream.make< JsonRpcMessage, JsonRpcMessage >(chrome.runtime, 'background', 'offscreen', isJsonRpcMessage); - // Set up RpcClient for backward compatibility with existing RPC methods - const rpcClient = new RpcClient( - kernelMethodSpecs, - async (request) => { - await offscreenStream.write(request); - }, - 'background:', - ); - // Set up CapTP for E() based communication with the kernel const backgroundCapTP = makeBackgroundCapTP({ send: (captpMessage: CapTPMessage) => { @@ -113,7 +103,8 @@ async function main(): Promise { const kernelPromise = backgroundCapTP.getKernel(); const ping = async (): Promise => { - const result = await rpcClient.call('ping', []); + const kernel = await kernelPromise; + const result = await E(kernel).ping(); logger.info(result); }; @@ -138,17 +129,12 @@ async function main(): Promise { }); try { - // Handle all incoming messages - await offscreenStream.drain(async (message) => { + // Handle incoming CapTP messages from the kernel + await offscreenStream.drain((message) => { if (isCapTPNotification(message)) { - // Dispatch CapTP messages const captpMessage = getCapTPMessage(message); backgroundCapTP.dispatch(captpMessage); - } else if (isJsonRpcResponse(message)) { - // Handle RPC responses - rpcClient.handleResponse(message.id as string, message); } - // Ignore other message types }); } catch (error) { throw new Error('Offscreen connection closed unexpectedly', { diff --git a/packages/omnium-gatherum/src/captp/index.ts b/packages/omnium-gatherum/src/captp/index.ts deleted file mode 100644 index cec1b1bb4..000000000 --- a/packages/omnium-gatherum/src/captp/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -export { - makeBackgroundCapTP, - makeCapTPNotification, - isCapTPNotification, - getCapTPMessage, - type BackgroundCapTP, - type BackgroundCapTPOptions, - type CapTPMessage, -} from './background-captp.ts'; - -export type { KernelFacade } from '@metamask/kernel-browser-runtime'; diff --git a/packages/omnium-gatherum/src/env/dev-console.js b/packages/omnium-gatherum/src/env/dev-console.js deleted file mode 100644 index 7c5d06d5e..000000000 --- a/packages/omnium-gatherum/src/env/dev-console.js +++ /dev/null @@ -1,9 +0,0 @@ -// We set this property on globalThis in the background before lockdown. -Object.defineProperty(globalThis, 'omnium', { - configurable: false, - enumerable: true, - writable: false, - value: {}, -}); - -export {}; diff --git a/packages/omnium-gatherum/src/env/dev-console.test.ts b/packages/omnium-gatherum/src/env/dev-console.test.ts deleted file mode 100644 index 0e7ad3f15..000000000 --- a/packages/omnium-gatherum/src/env/dev-console.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import './dev-console.js'; - -describe('dev-console', () => { - describe('omnium', () => { - it('is available on globalThis', async () => { - expect(omnium).toBeDefined(); - }); - - it('has expected property descriptors', async () => { - expect( - Object.getOwnPropertyDescriptor(globalThis, 'omnium'), - ).toMatchObject({ - configurable: false, - enumerable: true, - writable: false, - }); - }); - }); -}); diff --git a/packages/omnium-gatherum/src/global.d.ts b/packages/omnium-gatherum/src/global.d.ts index f64237f40..a275d71d9 100644 --- a/packages/omnium-gatherum/src/global.d.ts +++ b/packages/omnium-gatherum/src/global.d.ts @@ -1,4 +1,4 @@ -import type { KernelFacade } from './captp/index.ts'; +import type { KernelFacade } from '@metamask/kernel-browser-runtime'; // Type declarations for omnium dev console API. declare global { diff --git a/packages/omnium-gatherum/src/offscreen.ts b/packages/omnium-gatherum/src/offscreen.ts index f4bcf0768..0cf807894 100644 --- a/packages/omnium-gatherum/src/offscreen.ts +++ b/packages/omnium-gatherum/src/offscreen.ts @@ -25,8 +25,7 @@ async function main(): Promise { // Without this delay, sending messages via the chrome.runtime API can fail. await delay(50); - // Create stream for messages from the background script - // Uses JsonRpcMessage to support both RPC calls/responses and CapTP notifications + // Create stream for CapTP messages from the background script const backgroundStream = await ChromeRuntimeDuplexStream.make< JsonRpcMessage, JsonRpcMessage diff --git a/packages/omnium-gatherum/tsconfig.build.json b/packages/omnium-gatherum/tsconfig.build.json index 8da52bd25..d7b547202 100644 --- a/packages/omnium-gatherum/tsconfig.build.json +++ b/packages/omnium-gatherum/tsconfig.build.json @@ -21,10 +21,5 @@ { "path": "../ocap-kernel/tsconfig.build.json" }, { "path": "../streams/tsconfig.build.json" } ], - "include": [ - "./src/**/*.ts", - "./src/**/*.tsx", - "./src/**/*-trusted-prelude.js", - "./src/env/dev-console.js" - ] + "include": ["./src/**/*.ts", "./src/**/*.tsx"] } diff --git a/packages/omnium-gatherum/tsconfig.json b/packages/omnium-gatherum/tsconfig.json index 1197a400d..83fedfd08 100644 --- a/packages/omnium-gatherum/tsconfig.json +++ b/packages/omnium-gatherum/tsconfig.json @@ -27,8 +27,6 @@ "./playwright.config.ts", "./src/**/*.ts", "./src/**/*.tsx", - "./src/**/*-trusted-prelude.js", - "./src/env/dev-console.js", "./test/**/*.ts", "./vite.config.ts", "./vitest.config.ts" diff --git a/packages/omnium-gatherum/vite.config.ts b/packages/omnium-gatherum/vite.config.ts index 9e0c317ad..1c314ffff 100644 --- a/packages/omnium-gatherum/vite.config.ts +++ b/packages/omnium-gatherum/vite.config.ts @@ -37,7 +37,6 @@ const staticCopyTargets: readonly (string | Target)[] = [ // The extension manifest 'packages/omnium-gatherum/src/manifest.json', // Trusted prelude-related - 'packages/omnium-gatherum/src/env/dev-console.js', 'packages/kernel-shims/dist/endoify.js', ]; diff --git a/yarn.lock b/yarn.lock index 8b02d5738..3d3ae1ff3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2270,7 +2270,6 @@ __metadata: "@arethetypeswrong/cli": "npm:^0.17.4" "@endo/captp": "npm:^4.4.8" "@endo/marshal": "npm:^1.8.0" - "@endo/promise-kit": "npm:^1.1.13" "@metamask/auto-changelog": "npm:^5.0.1" "@metamask/eslint-config": "npm:^14.0.0" "@metamask/eslint-config-nodejs": "npm:^14.0.0" @@ -3442,19 +3441,17 @@ __metadata: resolution: "@ocap/extension@workspace:packages/extension" dependencies: "@arethetypeswrong/cli": "npm:^0.17.4" + "@endo/eventual-send": "npm:^1.3.4" "@metamask/auto-changelog": "npm:^5.0.1" "@metamask/eslint-config": "npm:^14.0.0" "@metamask/eslint-config-nodejs": "npm:^14.0.0" "@metamask/eslint-config-typescript": "npm:^14.0.0" "@metamask/kernel-browser-runtime": "workspace:^" - "@metamask/kernel-rpc-methods": "workspace:^" "@metamask/kernel-shims": "workspace:^" "@metamask/kernel-ui": "workspace:^" "@metamask/kernel-utils": "workspace:^" "@metamask/logger": "workspace:^" - "@metamask/ocap-kernel": "workspace:^" "@metamask/streams": "workspace:^" - "@metamask/utils": "npm:^11.4.2" "@ocap/cli": "workspace:^" "@ocap/kernel-test": "workspace:^" "@ocap/repo-tools": "workspace:^" @@ -3678,8 +3675,6 @@ __metadata: "@metamask/kernel-utils": "workspace:^" "@metamask/logger": "workspace:^" "@metamask/ocap-kernel": "workspace:^" - "@metamask/streams": "workspace:^" - "@metamask/utils": "npm:^11.4.2" "@ocap/cli": "workspace:^" "@ocap/kernel-language-model-service": "workspace:^" "@ocap/nodejs": "workspace:^" @@ -3827,7 +3822,6 @@ __metadata: "@metamask/logger": "workspace:^" "@metamask/ocap-kernel": "workspace:^" "@metamask/streams": "workspace:^" - "@metamask/utils": "npm:^11.4.2" "@ocap/cli": "workspace:^" "@ocap/kernel-platforms": "workspace:^" "@ocap/repo-tools": "workspace:^" @@ -3865,22 +3859,17 @@ __metadata: resolution: "@ocap/omnium-gatherum@workspace:packages/omnium-gatherum" dependencies: "@arethetypeswrong/cli": "npm:^0.17.4" - "@endo/captp": "npm:^4.4.8" "@endo/eventual-send": "npm:^1.3.4" - "@endo/marshal": "npm:^1.8.0" "@metamask/auto-changelog": "npm:^5.0.1" "@metamask/eslint-config": "npm:^14.0.0" "@metamask/eslint-config-nodejs": "npm:^14.0.0" "@metamask/eslint-config-typescript": "npm:^14.0.0" "@metamask/kernel-browser-runtime": "workspace:^" - "@metamask/kernel-rpc-methods": "workspace:^" "@metamask/kernel-shims": "workspace:^" "@metamask/kernel-ui": "workspace:^" "@metamask/kernel-utils": "workspace:^" "@metamask/logger": "workspace:^" - "@metamask/ocap-kernel": "workspace:^" "@metamask/streams": "workspace:^" - "@metamask/utils": "npm:^11.4.2" "@ocap/cli": "workspace:^" "@ocap/repo-tools": "workspace:^" "@playwright/test": "npm:^1.54.2" From 5b1cc1fcc2ddaac5a7870438406e7612fc6c72c3 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Wed, 7 Jan 2026 10:10:38 -0800 Subject: [PATCH 06/11] docs: Add plan for performing E() on vat objects --- .claude/plans/e-on-vat-objects.md | 159 ++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 .claude/plans/e-on-vat-objects.md diff --git a/.claude/plans/e-on-vat-objects.md b/.claude/plans/e-on-vat-objects.md new file mode 100644 index 000000000..5b71f4499 --- /dev/null +++ b/.claude/plans/e-on-vat-objects.md @@ -0,0 +1,159 @@ +# Plan: Enable E() Usage on Vat Objects from Background + +## Overview + +Bridge CapTP slots to kernel krefs, enabling `E()` usage on any kernel object reference from the extension background. This uses CapTP's documented extension point `makeCapTPImportExportTables` to intercept slot resolution and create presences backed by krefs that route through `kernel.queueMessage()`. + +## Key Insight + +The kernel already has `kernel-marshal.ts` that demonstrates the kref↔marshal bridging pattern with `kslot()` and `krefOf()`. We apply the same pattern to CapTP's slot system. + +## Architecture + +``` +Background Kernel Worker + │ │ + │ E(presence).method(args) │ + │ ────────────────────────► │ + │ (kref in slot, method call) │ + │ │ + │ │ queueMessage(kref, method, args) + │ │ ────────────────────────────► + │ │ Vat + │ result with krefs │ + │ ◄──────────────────────── │ + │ (auto-wrapped as presences) │ +``` + +## Implementation Phases + +### Phase 1: Kref-Aware Background CapTP + +**Files:** `packages/kernel-browser-runtime/src/background-captp.ts` + +1. Create `makeKrefImportExportTables()` function: + + - `exportSlot(obj)`: If obj is a kref presence, return the kref string + - `importSlot(slot)`: If slot is a kref string, create/return a presence + +2. Create `makeKrefPresence(kref, sendToKernel)` factory: + + - Uses `resolveWithPresence(handler)` from `@endo/promise-kit` + - Handler routes `GET`, `CALL`, `SEND` through kernel + - Caches presences by kref to ensure identity stability + +3. Modify `makeBackgroundCapTP()`: + - Accept `makeCapTPImportExportTables` option + - Wire up kref tables to CapTP instance + +**Key Code Pattern:** + +```typescript +function makeKrefPresence(kref: string, sendToKernel: SendFn): object { + const { resolve, promise } = makePromiseKit(); + resolve( + resolveWithPresence({ + applyMethod(_target, method, args) { + return sendToKernel('queueMessage', { target: kref, method, args }); + }, + }), + ); + return promise; +} +``` + +### Phase 2: Kernel-Side Kref Serialization + +**Files:** `packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.ts` + +1. Modify kernel CapTP to use kref-aware slot tables +2. When serializing results, convert kernel objects to kref strings +3. When deserializing arguments, convert kref strings to kernel dispatch targets + +### Phase 3: Public API + +**Files:** `packages/kernel-browser-runtime/src/background-captp.ts` + +Export utilities: + +- `resolveKref(kref: string): Promise` - Get E()-usable presence for a kref +- `isKrefPresence(obj: unknown): boolean` - Type guard +- `krefOf(presence: object): string | undefined` - Extract kref from presence + +### Phase 4: Promise Kref Handling + +**Files:** Background and kernel CapTP files + +1. Handle `kp*` (kernel promise) krefs specially +2. Subscribe to promise resolution via kernel +3. Forward resolution/rejection to background promise +4. Add `subscribePromise(kpref)` to KernelFacade + +### Phase 5: Argument Serialization + +**Files:** Background CapTP + +1. When calling `E(presence).method(arg1, arg2)`, serialize args through kref tables +2. Local objects passed as args need special handling (potential future export) +3. For Phase 1, only support passing kref presences and primitives as arguments + +### Phase 6: Garbage Collection + +**Files:** Background CapTP, KernelFacade + +1. Use `FinalizationRegistry` to detect when presences are GC'd +2. Batch and send `dropKref(kref)` to kernel +3. Add `dropKref(kref: string)` method to KernelFacade +4. Kernel routes to appropriate vat for cleanup + +## File Changes Summary + +| File | Changes | +| ----------------------------------------------------------------- | --------------------------------------------- | +| `kernel-browser-runtime/src/background-captp.ts` | Add kref tables, presence factory, public API | +| `kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.ts` | Add kref serialization | +| `kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.ts` | Add `dropKref`, `subscribePromise` | +| `kernel-browser-runtime/src/index.ts` | Export new utilities | + +## Dependencies + +- `@endo/promise-kit` - For `resolveWithPresence` +- `@endo/captp` - Existing, use `makeCapTPImportExportTables` option + +## Testing Strategy + +1. Unit tests for kref presence factory +2. Unit tests for import/export tables +3. Integration test: Background → Kernel → Vat round-trip +4. Test nested objects with multiple krefs +5. Test promise kref resolution +6. Test GC cleanup (may need manual triggering) + +## Success Criteria + +```typescript +// In background console: +const kernel = await kernel.getKernel(); +const counterRef = await E(kernel).resolveKref('ko42'); // Get presence for a kref +const count = await E(counterRef).increment(); // E() works! +const nested = await E(counterRef).getRelated(); // Returns more presences +await E(nested.child).doSomething(); // Nested presences work +``` + +## Open Questions + +1. **Initial kref discovery**: How does background learn about krefs? Options: + + - `getStatus()` returns caplet export krefs + - Registry vat pattern from PLAN.md Phase 2 + - Explicit `getCapletExports(subclusterId)` method + +2. **Bidirectional exports**: Should background be able to export objects to vats? + - Phase 1: No (background is consumer only) + - Future: Yes (requires reverse slot mapping) + +## Risks + +- **Performance**: Each E() call goes through kernel message queue +- **Memory leaks**: If FinalizationRegistry doesn't fire, krefs accumulate +- **Complexity**: Full object graph means any result can contain arbitrarily nested presences From 9f219d5535e1fae12103232d1e0d227599994911 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Wed, 7 Jan 2026 11:24:52 -0800 Subject: [PATCH 07/11] test(kernel-browser-runtime): Add CapTP infrastructure tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive tests for the CapTP infrastructure: - background-captp.test.ts: Tests for utility functions and makeBackgroundCapTP - kernel-facade.test.ts: Tests for facade delegation to kernel methods - kernel-captp.test.ts: Tests for makeKernelCapTP factory - captp.integration.test.ts: Full round-trip E() tests with real endoify Configure vitest with inline projects to use different setupFiles: - Unit tests use mock-endoify for isolated testing - Integration tests use real endoify for CapTP/E() functionality 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/kernel-browser-runtime/package.json | 1 + .../src/background-captp.test.ts | 166 +++++++++++++++ .../kernel-browser-runtime/src/index.test.ts | 2 + .../captp/captp.integration.test.ts | 194 +++++++++++++++++ .../kernel-worker/captp/kernel-captp.test.ts | 100 +++++++++ .../kernel-worker/captp/kernel-facade.test.ts | 196 ++++++++++++++++++ .../kernel-browser-runtime/vitest.config.ts | 64 ++++-- yarn.lock | 1 + 8 files changed, 708 insertions(+), 16 deletions(-) create mode 100644 packages/kernel-browser-runtime/src/background-captp.test.ts create mode 100644 packages/kernel-browser-runtime/src/kernel-worker/captp/captp.integration.test.ts create mode 100644 packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.test.ts create mode 100644 packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.test.ts diff --git a/packages/kernel-browser-runtime/package.json b/packages/kernel-browser-runtime/package.json index daccf583f..77c8c5d43 100644 --- a/packages/kernel-browser-runtime/package.json +++ b/packages/kernel-browser-runtime/package.json @@ -83,6 +83,7 @@ }, "devDependencies": { "@arethetypeswrong/cli": "^0.17.4", + "@endo/eventual-send": "^1.3.4", "@metamask/auto-changelog": "^5.0.1", "@metamask/eslint-config": "^14.0.0", "@metamask/eslint-config-nodejs": "^14.0.0", diff --git a/packages/kernel-browser-runtime/src/background-captp.test.ts b/packages/kernel-browser-runtime/src/background-captp.test.ts new file mode 100644 index 000000000..3d2dc25fb --- /dev/null +++ b/packages/kernel-browser-runtime/src/background-captp.test.ts @@ -0,0 +1,166 @@ +import '@ocap/repo-tools/test-utils/mock-endoify'; + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { + isCapTPNotification, + getCapTPMessage, + makeCapTPNotification, + makeBackgroundCapTP, +} from './background-captp.ts'; +import type { CapTPMessage, CapTPNotification } from './background-captp.ts'; + +describe('isCapTPNotification', () => { + it('returns true for valid CapTP notification', () => { + const notification = { + jsonrpc: '2.0', + method: 'captp', + params: [{ type: 'foo' }], + }; + expect(isCapTPNotification(notification)).toBe(true); + }); + + it('returns false when method is not "captp"', () => { + const message = { + jsonrpc: '2.0', + method: 'other', + params: [{ type: 'foo' }], + }; + expect(isCapTPNotification(message)).toBe(false); + }); + + it('returns false when params is not an array', () => { + const message = { + jsonrpc: '2.0', + method: 'captp', + params: { type: 'foo' }, + }; + expect(isCapTPNotification(message as never)).toBe(false); + }); + + it('returns false when params is empty', () => { + const message = { + jsonrpc: '2.0', + method: 'captp', + params: [], + }; + expect(isCapTPNotification(message)).toBe(false); + }); + + it('returns false when params has more than one element', () => { + const message = { + jsonrpc: '2.0', + method: 'captp', + params: [{ type: 'foo' }, { type: 'bar' }], + }; + expect(isCapTPNotification(message)).toBe(false); + }); + + it('returns true for JSON-RPC request with id if it matches captp format', () => { + // A request with an id is still a valid captp message format-wise + const request = { + jsonrpc: '2.0', + id: 1, + method: 'captp', + params: [{ type: 'foo' }], + }; + expect(isCapTPNotification(request)).toBe(true); + }); +}); + +describe('getCapTPMessage', () => { + it('extracts CapTP message from valid notification', () => { + const captpMessage: CapTPMessage = { type: 'CTP_CALL', methargs: [] }; + const notification: CapTPNotification = { + jsonrpc: '2.0', + method: 'captp', + params: [captpMessage], + }; + expect(getCapTPMessage(notification)).toStrictEqual(captpMessage); + }); + + it('throws for non-CapTP notification', () => { + const message = { + jsonrpc: '2.0', + method: 'other', + params: [], + }; + expect(() => getCapTPMessage(message)).toThrow('Not a CapTP notification'); + }); + + it('throws when params is empty', () => { + const message = { + jsonrpc: '2.0', + method: 'captp', + params: [], + }; + expect(() => getCapTPMessage(message)).toThrow('Not a CapTP notification'); + }); +}); + +describe('makeCapTPNotification', () => { + it('wraps CapTP message in JSON-RPC notification', () => { + const captpMessage: CapTPMessage = { type: 'CTP_CALL', target: 'ko1' }; + const result = makeCapTPNotification(captpMessage); + + expect(result).toStrictEqual({ + jsonrpc: '2.0', + method: 'captp', + params: [captpMessage], + }); + }); + + it('creates valid notification that passes isCapTPNotification', () => { + const captpMessage: CapTPMessage = { type: 'CTP_RESOLVE' }; + const notification = makeCapTPNotification(captpMessage); + + expect(isCapTPNotification(notification)).toBe(true); + }); +}); + +describe('makeBackgroundCapTP', () => { + let sendMock: ReturnType; + + beforeEach(() => { + sendMock = vi.fn(); + }); + + it('returns object with dispatch, getKernel, and abort', () => { + const capTP = makeBackgroundCapTP({ send: sendMock }); + + expect(capTP).toHaveProperty('dispatch'); + expect(capTP).toHaveProperty('getKernel'); + expect(capTP).toHaveProperty('abort'); + expect(typeof capTP.dispatch).toBe('function'); + expect(typeof capTP.getKernel).toBe('function'); + expect(typeof capTP.abort).toBe('function'); + }); + + it('getKernel returns a promise', () => { + const capTP = makeBackgroundCapTP({ send: sendMock }); + const result = capTP.getKernel(); + + expect(result).toBeInstanceOf(Promise); + }); + + it('calls send function when dispatching bootstrap request', () => { + const capTP = makeBackgroundCapTP({ send: sendMock }); + + // Calling getKernel triggers a bootstrap request (ignore unhandled promise) + capTP.getKernel().catch(() => undefined); + + // CapTP should have sent a message to request bootstrap + expect(sendMock).toHaveBeenCalled(); + const sentMessage = sendMock.mock.calls[0][0] as CapTPMessage; + expect(sentMessage).toBeDefined(); + }); + + it('dispatch returns boolean', () => { + const capTP = makeBackgroundCapTP({ send: sendMock }); + + // Dispatch a dummy message (will return false since it's not a valid CapTP message) + const result = capTP.dispatch({ type: 'unknown' }); + + expect(typeof result).toBe('boolean'); + }); +}); diff --git a/packages/kernel-browser-runtime/src/index.test.ts b/packages/kernel-browser-runtime/src/index.test.ts index f52b98667..8464486d9 100644 --- a/packages/kernel-browser-runtime/src/index.test.ts +++ b/packages/kernel-browser-runtime/src/index.test.ts @@ -1,3 +1,5 @@ +import '@ocap/repo-tools/test-utils/mock-endoify'; + import { describe, expect, it } from 'vitest'; import * as indexModule from './index.ts'; diff --git a/packages/kernel-browser-runtime/src/kernel-worker/captp/captp.integration.test.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/captp.integration.test.ts new file mode 100644 index 000000000..58212db92 --- /dev/null +++ b/packages/kernel-browser-runtime/src/kernel-worker/captp/captp.integration.test.ts @@ -0,0 +1,194 @@ +// Real endoify needed for CapTP and E() to work properly +// eslint-disable-next-line import-x/no-extraneous-dependencies +import '@metamask/kernel-shims/endoify'; + +import { E } from '@endo/eventual-send'; +import type { ClusterConfig, Kernel } from '@metamask/ocap-kernel'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { makeKernelCapTP } from './kernel-captp.ts'; +import { makeBackgroundCapTP } from '../../background-captp.ts'; +import type { CapTPMessage } from '../../background-captp.ts'; + +/** + * Integration tests for CapTP communication between background and kernel endpoints. + * + * These tests validate that the two CapTP endpoints can communicate correctly + * and that E() works properly with the kernel facade remote presence. + */ +describe('CapTP Integration', () => { + let mockKernel: Kernel; + let kernelCapTP: ReturnType; + let backgroundCapTP: ReturnType; + + beforeEach(() => { + // Create mock kernel with method implementations + mockKernel = { + launchSubcluster: vi.fn().mockResolvedValue({ + body: '#{"rootKref":"ko1"}', + slots: ['ko1'], + }), + terminateSubcluster: vi.fn().mockResolvedValue(undefined), + queueMessage: vi.fn().mockResolvedValue({ + body: '#{"result":"message-sent"}', + slots: [], + }), + getStatus: vi.fn().mockResolvedValue({ + vats: [{ id: 'v1', name: 'test-vat' }], + subclusters: ['sc1'], + remoteComms: false, + }), + pingVat: vi.fn().mockResolvedValue({ + pingVatResult: 'pong', + roundTripMs: 5, + }), + } as unknown as Kernel; + + // Wire up CapTP endpoints to dispatch messages synchronously to each other + // This simulates direct message passing for testing + + // Kernel-side: exposes facade as bootstrap + kernelCapTP = makeKernelCapTP({ + kernel: mockKernel, + send: (message: CapTPMessage) => { + // Dispatch synchronously for testing + backgroundCapTP.dispatch(message); + }, + }); + + // Background-side: gets remote presence of kernel + backgroundCapTP = makeBackgroundCapTP({ + send: (message: CapTPMessage) => { + // Dispatch synchronously for testing + kernelCapTP.dispatch(message); + }, + }); + }); + + describe('bootstrap', () => { + it('background can get kernel remote presence via getKernel', async () => { + // Request the kernel facade - with synchronous dispatch, this resolves immediately + const kernel = await backgroundCapTP.getKernel(); + expect(kernel).toBeDefined(); + }); + }); + + describe('ping', () => { + it('e(kernel).ping() returns "pong"', async () => { + // Get kernel remote presence + const kernel = await backgroundCapTP.getKernel(); + + // Call ping via E() + const result = await E(kernel).ping(); + expect(result).toBe('pong'); + }); + }); + + describe('getStatus', () => { + it('e(kernel).getStatus() returns status from mock kernel', async () => { + // Get kernel remote presence + const kernel = await backgroundCapTP.getKernel(); + + // Call getStatus via E() + const result = await E(kernel).getStatus(); + expect(result).toStrictEqual({ + vats: [{ id: 'v1', name: 'test-vat' }], + subclusters: ['sc1'], + remoteComms: false, + }); + + expect(mockKernel.getStatus).toHaveBeenCalled(); + }); + }); + + describe('launchSubcluster', () => { + it('e(kernel).launchSubcluster() passes arguments correctly', async () => { + const config: ClusterConfig = { + bootstrap: 'v1', + vats: { + v1: { + bundleSpec: 'test-source', + }, + }, + }; + + // Get kernel remote presence + const kernel = await backgroundCapTP.getKernel(); + + // Call launchSubcluster via E() + const result = await E(kernel).launchSubcluster(config); + expect(result).toStrictEqual({ + body: '#{"rootKref":"ko1"}', + slots: ['ko1'], + }); + + expect(mockKernel.launchSubcluster).toHaveBeenCalledWith(config); + }); + }); + + describe('terminateSubcluster', () => { + it('e(kernel).terminateSubcluster() delegates to kernel', async () => { + // Get kernel remote presence + const kernel = await backgroundCapTP.getKernel(); + + // Call terminateSubcluster via E() + await E(kernel).terminateSubcluster('sc1'); + expect(mockKernel.terminateSubcluster).toHaveBeenCalledWith('sc1'); + }); + }); + + describe('queueMessage', () => { + it('e(kernel).queueMessage() passes arguments correctly', async () => { + const target = 'ko1'; + const method = 'doSomething'; + const args = ['arg1', { nested: 'value' }]; + + // Get kernel remote presence + const kernel = await backgroundCapTP.getKernel(); + + // Call queueMessage via E() + const result = await E(kernel).queueMessage(target, method, args); + expect(result).toStrictEqual({ + body: '#{"result":"message-sent"}', + slots: [], + }); + + expect(mockKernel.queueMessage).toHaveBeenCalledWith( + target, + method, + args, + ); + }); + }); + + describe('pingVat', () => { + it('e(kernel).pingVat() delegates to kernel', async () => { + // Get kernel remote presence + const kernel = await backgroundCapTP.getKernel(); + + // Call pingVat via E() + const result = await E(kernel).pingVat('v1'); + expect(result).toStrictEqual({ + pingVatResult: 'pong', + roundTripMs: 5, + }); + + expect(mockKernel.pingVat).toHaveBeenCalledWith('v1'); + }); + }); + + describe('error propagation', () => { + it('errors from kernel methods propagate to background', async () => { + const error = new Error('Kernel operation failed'); + vi.mocked(mockKernel.getStatus).mockRejectedValueOnce(error); + + // Get kernel remote presence + const kernel = await backgroundCapTP.getKernel(); + + // Call getStatus which will fail + await expect(E(kernel).getStatus()).rejects.toThrow( + 'Kernel operation failed', + ); + }); + }); +}); diff --git a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.test.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.test.ts new file mode 100644 index 000000000..32b617992 --- /dev/null +++ b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.test.ts @@ -0,0 +1,100 @@ +import '@ocap/repo-tools/test-utils/mock-endoify'; + +import type { Kernel } from '@metamask/ocap-kernel'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { makeKernelCapTP } from './kernel-captp.ts'; +import type { CapTPMessage } from './kernel-captp.ts'; + +describe('makeKernelCapTP', () => { + let mockKernel: Kernel; + let sendMock: ReturnType; + + beforeEach(() => { + mockKernel = { + launchSubcluster: vi.fn().mockResolvedValue({ + body: '#{"status":"ok"}', + slots: [], + }), + terminateSubcluster: vi.fn().mockResolvedValue(undefined), + queueMessage: vi.fn().mockResolvedValue({ + body: '#{"result":"success"}', + slots: [], + }), + getStatus: vi.fn().mockResolvedValue({ + vats: [], + subclusters: [], + remoteComms: false, + }), + pingVat: vi.fn().mockResolvedValue({ + pingVatResult: 'pong', + roundTripMs: 10, + }), + } as unknown as Kernel; + + sendMock = vi.fn(); + }); + + it('returns object with dispatch and abort', () => { + const capTP = makeKernelCapTP({ + kernel: mockKernel, + send: sendMock, + }); + + expect(capTP).toHaveProperty('dispatch'); + expect(capTP).toHaveProperty('abort'); + expect(typeof capTP.dispatch).toBe('function'); + expect(typeof capTP.abort).toBe('function'); + }); + + it('dispatch returns boolean', () => { + const capTP = makeKernelCapTP({ + kernel: mockKernel, + send: sendMock, + }); + + // Dispatch a dummy message - will return false since it's not valid + const result = capTP.dispatch({ type: 'unknown' }); + + expect(typeof result).toBe('boolean'); + }); + + it('processes valid CapTP messages without errors', () => { + const capTP = makeKernelCapTP({ + kernel: mockKernel, + send: sendMock, + }); + + // Dispatch a valid CapTP message format + // CapTP uses array-based message format internally + // A CTP_CALL message triggers method calls on the bootstrap object + const callMessage: CapTPMessage = { + type: 'CTP_CALL', + questionID: 1, + target: 0, // Bootstrap slot + method: 'ping', + args: { body: '[]', slots: [] }, + }; + + // Should not throw when processing a message + expect(() => capTP.dispatch(callMessage)).not.toThrow(); + }); + + it('abort does not throw', () => { + const capTP = makeKernelCapTP({ + kernel: mockKernel, + send: sendMock, + }); + + expect(() => capTP.abort()).not.toThrow(); + }); + + it('abort can be called with a reason', () => { + const capTP = makeKernelCapTP({ + kernel: mockKernel, + send: sendMock, + }); + + expect(() => capTP.abort({ reason: 'test shutdown' })).not.toThrow(); + }); +}); diff --git a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.test.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.test.ts new file mode 100644 index 000000000..acd1f4628 --- /dev/null +++ b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.test.ts @@ -0,0 +1,196 @@ +import '@ocap/repo-tools/test-utils/mock-endoify'; + +import type { ClusterConfig, Kernel, KRef, VatId } from '@metamask/ocap-kernel'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { makeKernelFacade } from './kernel-facade.ts'; +import type { KernelFacade } from './kernel-facade.ts'; + +describe('makeKernelFacade', () => { + let mockKernel: Kernel; + let facade: KernelFacade; + + beforeEach(() => { + mockKernel = { + launchSubcluster: vi.fn().mockResolvedValue({ + body: '#{"status":"ok"}', + slots: [], + }), + terminateSubcluster: vi.fn().mockResolvedValue(undefined), + queueMessage: vi.fn().mockResolvedValue({ + body: '#{"result":"success"}', + slots: [], + }), + getStatus: vi.fn().mockResolvedValue({ + vats: [], + subclusters: [], + remoteComms: false, + }), + pingVat: vi.fn().mockResolvedValue({ + pingVatResult: 'pong', + roundTripMs: 10, + }), + } as unknown as Kernel; + + facade = makeKernelFacade(mockKernel); + }); + + describe('ping', () => { + it('returns "pong"', async () => { + const result = await facade.ping(); + expect(result).toBe('pong'); + }); + }); + + describe('launchSubcluster', () => { + it('delegates to kernel with correct arguments', async () => { + const config: ClusterConfig = { + name: 'test-cluster', + vats: [ + { + name: 'test-vat', + bundleSpec: { type: 'literal', source: 'test' }, + }, + ], + }; + + await facade.launchSubcluster(config); + + expect(mockKernel.launchSubcluster).toHaveBeenCalledWith(config); + expect(mockKernel.launchSubcluster).toHaveBeenCalledTimes(1); + }); + + it('returns result from kernel', async () => { + const expectedResult = { body: '#{"rootObject":"ko1"}', slots: ['ko1'] }; + vi.mocked(mockKernel.launchSubcluster).mockResolvedValueOnce( + expectedResult, + ); + + const config: ClusterConfig = { + name: 'test-cluster', + vats: [], + }; + + const result = await facade.launchSubcluster(config); + expect(result).toStrictEqual(expectedResult); + }); + + it('propagates errors from kernel', async () => { + const error = new Error('Launch failed'); + vi.mocked(mockKernel.launchSubcluster).mockRejectedValueOnce(error); + + const config: ClusterConfig = { + name: 'test-cluster', + vats: [], + }; + + await expect(facade.launchSubcluster(config)).rejects.toThrow(error); + }); + }); + + describe('terminateSubcluster', () => { + it('delegates to kernel with correct arguments', async () => { + const subclusterId = 'sc1'; + + await facade.terminateSubcluster(subclusterId); + + expect(mockKernel.terminateSubcluster).toHaveBeenCalledWith(subclusterId); + expect(mockKernel.terminateSubcluster).toHaveBeenCalledTimes(1); + }); + + it('propagates errors from kernel', async () => { + const error = new Error('Terminate failed'); + vi.mocked(mockKernel.terminateSubcluster).mockRejectedValueOnce(error); + + await expect(facade.terminateSubcluster('sc1')).rejects.toThrow(error); + }); + }); + + describe('queueMessage', () => { + it('delegates to kernel with correct arguments', async () => { + const target: KRef = 'ko1'; + const method = 'doSomething'; + const args = ['arg1', { nested: 'value' }]; + + await facade.queueMessage(target, method, args); + + expect(mockKernel.queueMessage).toHaveBeenCalledWith( + target, + method, + args, + ); + expect(mockKernel.queueMessage).toHaveBeenCalledTimes(1); + }); + + it('returns result from kernel', async () => { + const expectedResult = { body: '#{"answer":42}', slots: [] }; + vi.mocked(mockKernel.queueMessage).mockResolvedValueOnce(expectedResult); + + const result = await facade.queueMessage('ko1', 'compute', []); + expect(result).toStrictEqual(expectedResult); + }); + + it('propagates errors from kernel', async () => { + const error = new Error('Queue message failed'); + vi.mocked(mockKernel.queueMessage).mockRejectedValueOnce(error); + + await expect(facade.queueMessage('ko1', 'method', [])).rejects.toThrow( + error, + ); + }); + }); + + describe('getStatus', () => { + it('delegates to kernel', async () => { + await facade.getStatus(); + + expect(mockKernel.getStatus).toHaveBeenCalled(); + expect(mockKernel.getStatus).toHaveBeenCalledTimes(1); + }); + + it('returns status from kernel', async () => { + const expectedStatus = { + vats: [{ id: 'v1', name: 'test-vat' }], + subclusters: [], + remoteComms: true, + }; + vi.mocked(mockKernel.getStatus).mockResolvedValueOnce(expectedStatus); + + const result = await facade.getStatus(); + expect(result).toStrictEqual(expectedStatus); + }); + + it('propagates errors from kernel', async () => { + const error = new Error('Get status failed'); + vi.mocked(mockKernel.getStatus).mockRejectedValueOnce(error); + + await expect(facade.getStatus()).rejects.toThrow(error); + }); + }); + + describe('pingVat', () => { + it('delegates to kernel with correct vatId', async () => { + const vatId: VatId = 'v1'; + + await facade.pingVat(vatId); + + expect(mockKernel.pingVat).toHaveBeenCalledWith(vatId); + expect(mockKernel.pingVat).toHaveBeenCalledTimes(1); + }); + + it('returns result from kernel', async () => { + const expectedResult = { pingVatResult: 'pong', roundTripMs: 5 }; + vi.mocked(mockKernel.pingVat).mockResolvedValueOnce(expectedResult); + + const result = await facade.pingVat('v1'); + expect(result).toStrictEqual(expectedResult); + }); + + it('propagates errors from kernel', async () => { + const error = new Error('Ping vat failed'); + vi.mocked(mockKernel.pingVat).mockRejectedValueOnce(error); + + await expect(facade.pingVat('v1')).rejects.toThrow(error); + }); + }); +}); diff --git a/packages/kernel-browser-runtime/vitest.config.ts b/packages/kernel-browser-runtime/vitest.config.ts index 7ffeda649..f2a5ffb60 100644 --- a/packages/kernel-browser-runtime/vitest.config.ts +++ b/packages/kernel-browser-runtime/vitest.config.ts @@ -1,22 +1,54 @@ -import { mergeConfig } from '@ocap/repo-tools/vitest-config'; +import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { defineConfig, defineProject } from 'vitest/config'; +import { defineConfig } from 'vitest/config'; import defaultConfig from '../../vitest.config.ts'; -export default defineConfig((args) => { - return mergeConfig( - args, - defaultConfig, - defineProject({ - test: { - name: 'kernel-browser-runtime', - setupFiles: [ - fileURLToPath( - import.meta.resolve('@ocap/repo-tools/test-utils/mock-endoify'), - ), - ], +const { test: rootTest, ...rootViteConfig } = defaultConfig; + +// Common test configuration from root, minus projects and setupFiles +const { + projects: _projects, + setupFiles: _setupFiles, + ...commonTestConfig +} = rootTest ?? {}; + +export default defineConfig({ + ...rootViteConfig, + + test: { + projects: [ + // Unit tests with mock-endoify + { + test: { + ...commonTestConfig, + name: 'kernel-browser-runtime', + include: ['src/**/*.test.ts'], + exclude: ['**/*.integration.test.ts'], + setupFiles: [ + fileURLToPath( + import.meta.resolve('@ocap/repo-tools/test-utils/fetch-mock'), + ), + fileURLToPath( + import.meta.resolve('@ocap/repo-tools/test-utils/mock-endoify'), + ), + ], + }, + }, + // Integration tests with real endoify + { + test: { + ...commonTestConfig, + name: 'kernel-browser-runtime:integration', + include: ['src/**/*.integration.test.ts'], + setupFiles: [ + fileURLToPath( + import.meta.resolve('@ocap/repo-tools/test-utils/fetch-mock'), + ), + path.resolve(import.meta.dirname, '../kernel-shims/src/endoify.js'), + ], + }, }, - }), - ); + ], + }, }); diff --git a/yarn.lock b/yarn.lock index 3d3ae1ff3..656153339 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2269,6 +2269,7 @@ __metadata: dependencies: "@arethetypeswrong/cli": "npm:^0.17.4" "@endo/captp": "npm:^4.4.8" + "@endo/eventual-send": "npm:^1.3.4" "@endo/marshal": "npm:^1.8.0" "@metamask/auto-changelog": "npm:^5.0.1" "@metamask/eslint-config": "npm:^14.0.0" From 9708cd0d0cfc1aa89dbdd0dc8276a4051ababc2d Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Wed, 7 Jan 2026 16:35:01 -0800 Subject: [PATCH 08/11] feat(omnium): Add controller architecture and CapletController Implement Phase 1.2 of the omnium plan - Define Caplet Structure: - Add modular controller architecture with POLA attenuation via makeFacet() - Add storage abstraction layer (StorageAdapter, NamespacedStorage) - Add Chrome storage adapter for platform storage - Add CapletController for managing installed caplets - Add Caplet types with superstruct validation - Wire CapletController into background.ts and expose on globalThis.omnium.caplet - Add comprehensive unit tests for all controller code - Update PLAN.md to reflect implementation --- packages/omnium-gatherum/PLAN.md | 74 ++- packages/omnium-gatherum/package.json | 5 + packages/omnium-gatherum/src/background.ts | 67 ++- .../caplet/caplet-controller.test.ts | 491 ++++++++++++++++++ .../controllers/caplet/caplet-controller.ts | 298 +++++++++++ .../src/controllers/caplet/index.ts | 22 + .../src/controllers/caplet/types.test.ts | 140 +++++ .../src/controllers/caplet/types.ts | 108 ++++ .../src/controllers/facet.test.ts | 125 +++++ .../omnium-gatherum/src/controllers/facet.ts | 70 +++ .../omnium-gatherum/src/controllers/index.ts | 32 ++ .../storage/chrome-storage.test.ts | 132 +++++ .../src/controllers/storage/chrome-storage.ts | 38 ++ .../src/controllers/storage/index.ts | 3 + .../storage/namespaced-storage.test.ts | 156 ++++++ .../controllers/storage/namespaced-storage.ts | 52 ++ .../src/controllers/storage/types.ts | 88 ++++ .../omnium-gatherum/src/controllers/types.ts | 24 + packages/omnium-gatherum/src/global.d.ts | 66 +++ yarn.lock | 5 + 20 files changed, 1967 insertions(+), 29 deletions(-) create mode 100644 packages/omnium-gatherum/src/controllers/caplet/caplet-controller.test.ts create mode 100644 packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts create mode 100644 packages/omnium-gatherum/src/controllers/caplet/index.ts create mode 100644 packages/omnium-gatherum/src/controllers/caplet/types.test.ts create mode 100644 packages/omnium-gatherum/src/controllers/caplet/types.ts create mode 100644 packages/omnium-gatherum/src/controllers/facet.test.ts create mode 100644 packages/omnium-gatherum/src/controllers/facet.ts create mode 100644 packages/omnium-gatherum/src/controllers/index.ts create mode 100644 packages/omnium-gatherum/src/controllers/storage/chrome-storage.test.ts create mode 100644 packages/omnium-gatherum/src/controllers/storage/chrome-storage.ts create mode 100644 packages/omnium-gatherum/src/controllers/storage/index.ts create mode 100644 packages/omnium-gatherum/src/controllers/storage/namespaced-storage.test.ts create mode 100644 packages/omnium-gatherum/src/controllers/storage/namespaced-storage.ts create mode 100644 packages/omnium-gatherum/src/controllers/storage/types.ts create mode 100644 packages/omnium-gatherum/src/controllers/types.ts diff --git a/packages/omnium-gatherum/PLAN.md b/packages/omnium-gatherum/PLAN.md index a73ce5718..caf22976e 100644 --- a/packages/omnium-gatherum/PLAN.md +++ b/packages/omnium-gatherum/PLAN.md @@ -76,7 +76,7 @@ capabilities. - Phase 1: Basic promise resolution - Phase 2+: Promise pipelining supported by CapTP -- [ ] **Testing** +- [x] **Testing** - Tests to be added for CapTP-based approach **Note**: Using CapTP provides several advantages over a custom implementation: @@ -88,22 +88,57 @@ capabilities. #### 1.2 Define Caplet Structure -**Goal**: Establish the data structures and formats that define a Caplet. +**Goal**: Establish the data structures, storage abstractions, and controller architecture for Caplets. -- [ ] **Caplet Manifest Schema** +- [x] **Controller Architecture** - - Define a TypeScript type/superstruct for Caplet metadata: - - `id`: Unique identifier (string, e.g., `"com.example.bitcoin-signer"`) - - `name`: Human-readable name - - `version`: Semantic version - - `bundleSpec`: URI to the vat bundle (for now, local file paths or inline bundles) - - `requestedServices`: Array of service names this Caplet wants to consume (e.g., `["keyring", "network"]`) - - `providedServices`: Array of service names this Caplet exposes (e.g., `["bitcoin-signer"]`) - - `description`: Optional description - - `author`: Optional author info - - Location: Create `packages/omnium-gatherum/src/caplet/types.ts` + - Established modular controller pattern in `packages/omnium-gatherum/src/controllers/`: + - Controllers manage state and business logic + - Controllers communicate via `E()` for capability attenuation (POLA) + - Each controller receives namespaced storage (isolated key space) + - `controllers/types.ts`: Base controller types (`ControllerConfig`, `FacetOf`) + - `controllers/facet.ts`: `makeFacet()` utility for POLA attenuation between controllers -- [ ] **Caplet Vat Bundle Format** +- [x] **Storage Abstraction Layer** + + - `controllers/storage/types.ts`: Storage interfaces + - `StorageAdapter`: Low-level wrapper for platform storage APIs + - `NamespacedStorage`: Scoped storage interface with automatic key prefixing + - `controllers/storage/chrome-storage.ts`: `makeChromeStorageAdapter()` for Chrome Storage API + - `controllers/storage/namespaced-storage.ts`: `makeNamespacedStorage()` factory + - Storage keys automatically prefixed: `${namespace}.${key}` (e.g., `caplet.com.example.test.manifest`) + +- [x] **Caplet Manifest Schema** + + - Defined TypeScript types with superstruct validation in `controllers/caplet/types.ts`: + - `CapletId`: Reverse domain notation (e.g., `"com.example.bitcoin-signer"`) + - `SemVer`: Semantic version string (strict format, no `v` prefix) + - `CapletManifest`: Full manifest with id, name, version, bundleSpec, requestedServices, providedServices + - `InstalledCaplet`: Runtime record with manifest, subclusterId, installedAt timestamp + - Validation functions: `isCapletId()`, `isSemVer()`, `isCapletManifest()`, `assertCapletManifest()` + +- [x] **CapletController** + + - `controllers/caplet/caplet-controller.ts`: `makeCapletController()` manages installed caplets + - Methods: + - `install(manifest, bundle?)`: Validate manifest, launch subcluster, store metadata + - `uninstall(capletId)`: Terminate subcluster, remove metadata + - `list()`: Get all installed caplets + - `get(capletId)`: Get specific caplet + - `getByService(serviceName)`: Find caplet providing a service + - Storage keys (within `caplet` namespace): + - `installed`: Array of installed caplet IDs + - `${capletId}.manifest`: CapletManifest JSON + - `${capletId}.subclusterId`: Associated subcluster ID + - `${capletId}.installedAt`: Installation timestamp + +- [x] **Dev Console Integration** + + - Wired CapletController into `background.ts` + - Exposed on `globalThis.omnium.caplet`: + - `install(manifest, bundle?)`, `uninstall(capletId)`, `list()`, `get(capletId)`, `getByService(serviceName)` + +- [ ] **Caplet Vat Bundle Format** (Deferred) - A Caplet's code is a standard vat bundle (JSON output from `@endo/bundle-source`) - The vat must export `buildRootObject(vatPowers, parameters, baggage)` as per kernel conventions @@ -112,17 +147,6 @@ capabilities. - `shutdown()`: Cleanup hook - Document the Caplet vat contract in `packages/omnium-gatherum/docs/caplet-contract.md` -- [ ] **Caplet Storage Schema** - - Define how installed Caplets are persisted in **user space** (not kernel store): - - Use **Chrome Storage API** (`chrome.storage.local`) for omnium-specific data - - Maintains clean kernel/user space separation - kernel doesn't know about Caplets - - Storage keys: - - `caplet.${capletId}.manifest` → JSON manifest - - `caplet.${capletId}.subclusterId` → Associated subcluster ID - - `caplet.installed` → Array of installed Caplet IDs - - Location: `packages/omnium-gatherum/src/caplet/storage.ts` - - Note: This is omnium's own storage, separate from kernel store - #### 1.3 Implement Caplet Installation **Goal**: Enable loading a Caplet into omnium, creating its subcluster, and registering it. diff --git a/packages/omnium-gatherum/package.json b/packages/omnium-gatherum/package.json index 9b8cce0ce..b37f2251c 100644 --- a/packages/omnium-gatherum/package.json +++ b/packages/omnium-gatherum/package.json @@ -44,14 +44,19 @@ }, "dependencies": { "@endo/eventual-send": "^1.3.4", + "@endo/exo": "^1.5.12", "@metamask/kernel-browser-runtime": "workspace:^", "@metamask/kernel-shims": "workspace:^", "@metamask/kernel-ui": "workspace:^", "@metamask/kernel-utils": "workspace:^", "@metamask/logger": "workspace:^", + "@metamask/ocap-kernel": "workspace:^", "@metamask/streams": "workspace:^", + "@metamask/superstruct": "^3.2.1", + "@metamask/utils": "^11.4.2", "react": "^17.0.2", "react-dom": "^17.0.2", + "semver": "^7.7.1", "ses": "^1.14.0" }, "devDependencies": { diff --git a/packages/omnium-gatherum/src/background.ts b/packages/omnium-gatherum/src/background.ts index 559da4dbf..658214bb9 100644 --- a/packages/omnium-gatherum/src/background.ts +++ b/packages/omnium-gatherum/src/background.ts @@ -12,8 +12,16 @@ import type { import { delay, isJsonRpcMessage } from '@metamask/kernel-utils'; import type { JsonRpcMessage } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; +import type { ClusterConfig } from '@metamask/ocap-kernel'; import { ChromeRuntimeDuplexStream } from '@metamask/streams/browser'; +import { + makeChromeStorageAdapter, + makeNamespacedStorage, + makeCapletController, +} from './controllers/index.ts'; +import type { CapletManifest, LaunchResult } from './controllers/index.ts'; + defineGlobals(); const OFFSCREEN_DOCUMENT_PATH = '/offscreen.html'; @@ -100,19 +108,58 @@ async function main(): Promise { }); // Get the kernel remote presence - const kernelPromise = backgroundCapTP.getKernel(); + const kernelP = backgroundCapTP.getKernel(); const ping = async (): Promise => { - const kernel = await kernelPromise; - const result = await E(kernel).ping(); + const result = await E(kernelP).ping(); logger.info(result); }; // Helper to get the kernel remote presence (for use with E()) const getKernel = async (): Promise => { - return kernelPromise; + return kernelP; }; + // Create storage adapter and namespaced storage for caplets + const storageAdapter = makeChromeStorageAdapter(); + const capletStorage = makeNamespacedStorage('caplet', storageAdapter); + + // Create CapletController with attenuated kernel access + const capletController = makeCapletController( + { logger: logger.subLogger({ tags: ['caplet'] }) }, + { + storage: capletStorage, + // Wrap launchSubcluster to return subclusterId + launchSubcluster: async ( + config: ClusterConfig, + ): Promise => { + // Get current subcluster count + const statusBefore = await E(kernelP).getStatus(); + const beforeIds = new Set( + statusBefore.subclusters.map((subcluster) => subcluster.id), + ); + + // Launch the subcluster + await E(kernelP).launchSubcluster(config); + + // Get status after and find the new subcluster + const statusAfter = await E(kernelP).getStatus(); + const newSubcluster = statusAfter.subclusters.find( + (subcluster) => !beforeIds.has(subcluster.id), + ); + + if (!newSubcluster) { + throw new Error('Failed to determine subclusterId after launch'); + } + + return { subclusterId: newSubcluster.id }; + }, + terminateSubcluster: async (subclusterId: string): Promise => { + await E(kernelP).terminateSubcluster(subclusterId); + }, + }, + ); + Object.defineProperties(globalThis.omnium, { ping: { value: ping, @@ -120,6 +167,18 @@ async function main(): Promise { getKernel: { value: getKernel, }, + caplet: { + value: harden({ + install: async (manifest: CapletManifest, bundle?: unknown) => + E(capletController).install(manifest, bundle), + uninstall: async (capletId: string) => + E(capletController).uninstall(capletId), + list: async () => E(capletController).list(), + get: async (capletId: string) => E(capletController).get(capletId), + getByService: async (serviceName: string) => + E(capletController).getByService(serviceName), + }), + }, }); harden(globalThis.omnium); diff --git a/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.test.ts b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.test.ts new file mode 100644 index 000000000..0da7d6b59 --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.test.ts @@ -0,0 +1,491 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { makeCapletController } from './caplet-controller.ts'; +import type { CapletManifest } from './types.ts'; +import type { NamespacedStorage } from '../storage/types.ts'; +import type { ControllerConfig } from '../types.ts'; + +describe('makeCapletController', () => { + const mockLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + subLogger: vi.fn().mockReturnThis(), + }; + + const mockStorage: NamespacedStorage = { + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), + has: vi.fn(), + keys: vi.fn(), + clear: vi.fn(), + }; + + const mockLaunchSubcluster = vi.fn(); + const mockTerminateSubcluster = vi.fn(); + + const config: ControllerConfig = { + logger: mockLogger as unknown as ControllerConfig['logger'], + }; + + const deps = { + storage: mockStorage, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }; + + const validManifest: CapletManifest = { + id: 'com.example.test', + name: 'Test Caplet', + version: '1.0.0', + bundleSpec: 'https://example.com/bundle.json', + requestedServices: ['keyring'], + providedServices: ['signer'], + }; + + beforeEach(() => { + vi.mocked(mockStorage.has).mockResolvedValue(false); + vi.mocked(mockStorage.keys).mockResolvedValue([]); + vi.mocked(mockLaunchSubcluster).mockResolvedValue({ + subclusterId: 'subcluster-123', + }); + }); + + describe('install', () => { + it('installs a caplet successfully', async () => { + const controller = makeCapletController(config, deps); + const result = await controller.install(validManifest); + + expect(result).toStrictEqual({ + capletId: 'com.example.test', + subclusterId: 'subcluster-123', + }); + }); + + it('validates the manifest', async () => { + const controller = makeCapletController(config, deps); + const invalidManifest = { id: 'invalid' } as CapletManifest; + + await expect(controller.install(invalidManifest)).rejects.toThrow( + 'Invalid caplet manifest for invalid', + ); + }); + + it('throws if caplet already installed', async () => { + vi.mocked(mockStorage.get).mockImplementation(async (key: string) => { + if (key === 'com.example.test.manifest') { + return validManifest; + } + return undefined; + }); + + const controller = makeCapletController(config, deps); + + await expect(controller.install(validManifest)).rejects.toThrow( + 'Caplet com.example.test is already installed', + ); + }); + + it('launches subcluster with correct config', async () => { + const controller = makeCapletController(config, deps); + await controller.install(validManifest); + + expect(mockLaunchSubcluster).toHaveBeenCalledWith({ + bootstrap: 'com.example.test', + vats: { + 'com.example.test': { + bundleSpec: 'https://example.com/bundle.json', + }, + }, + }); + }); + + it('stores manifest, subclusterId, and installedAt', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-01-15T12:00:00Z')); + + const controller = makeCapletController(config, deps); + await controller.install(validManifest); + + expect(mockStorage.set).toHaveBeenCalledWith( + 'com.example.test.manifest', + validManifest, + ); + expect(mockStorage.set).toHaveBeenCalledWith( + 'com.example.test.subclusterId', + 'subcluster-123', + ); + expect(mockStorage.set).toHaveBeenCalledWith( + 'com.example.test.installedAt', + Date.now(), + ); + + vi.useRealTimers(); + }); + + it('updates installed list', async () => { + vi.mocked(mockStorage.get).mockImplementation(async (key: string) => { + if (key === 'installed') { + return ['com.other.caplet']; + } + return undefined; + }); + + const controller = makeCapletController(config, deps); + await controller.install(validManifest); + + expect(mockStorage.set).toHaveBeenCalledWith('installed', [ + 'com.other.caplet', + 'com.example.test', + ]); + }); + + it('does not duplicate caplet id in installed list', async () => { + vi.mocked(mockStorage.get).mockImplementation(async (key: string) => { + if (key === 'installed') { + return ['com.example.test']; + } + // Return undefined for manifest to allow install to proceed + return undefined; + }); + + const controller = makeCapletController(config, deps); + await controller.install(validManifest); + + // Should not add duplicate + expect(mockStorage.set).not.toHaveBeenCalledWith('installed', [ + 'com.example.test', + 'com.example.test', + ]); + }); + + it('logs installation progress', async () => { + const controller = makeCapletController(config, deps); + await controller.install(validManifest); + + expect(mockLogger.info).toHaveBeenCalledWith( + 'Installing caplet: com.example.test', + ); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Caplet com.example.test installed with subcluster subcluster-123', + ); + }); + }); + + describe('uninstall', () => { + it('uninstalls a caplet successfully', async () => { + vi.mocked(mockStorage.get).mockImplementation(async (key: string) => { + if (key === 'com.example.test.subclusterId') { + return 'subcluster-123'; + } + if (key === 'installed') { + return ['com.example.test']; + } + return undefined; + }); + + const controller = makeCapletController(config, deps); + await controller.uninstall('com.example.test'); + + expect(mockTerminateSubcluster).toHaveBeenCalledWith('subcluster-123'); + }); + + it('throws if caplet not found', async () => { + const controller = makeCapletController(config, deps); + + await expect( + controller.uninstall('com.example.notfound'), + ).rejects.toThrow('Caplet com.example.notfound not found'); + }); + + it('removes all caplet data from storage', async () => { + vi.mocked(mockStorage.get).mockImplementation(async (key: string) => { + if (key === 'com.example.test.subclusterId') { + return 'subcluster-123'; + } + if (key === 'installed') { + return ['com.example.test']; + } + return undefined; + }); + + const controller = makeCapletController(config, deps); + await controller.uninstall('com.example.test'); + + expect(mockStorage.delete).toHaveBeenCalledWith( + 'com.example.test.manifest', + ); + expect(mockStorage.delete).toHaveBeenCalledWith( + 'com.example.test.subclusterId', + ); + expect(mockStorage.delete).toHaveBeenCalledWith( + 'com.example.test.installedAt', + ); + }); + + it('updates installed list', async () => { + vi.mocked(mockStorage.get).mockImplementation(async (key: string) => { + if (key === 'com.example.test.subclusterId') { + return 'subcluster-123'; + } + if (key === 'installed') { + return ['com.other.caplet', 'com.example.test']; + } + return undefined; + }); + + const controller = makeCapletController(config, deps); + await controller.uninstall('com.example.test'); + + expect(mockStorage.set).toHaveBeenCalledWith('installed', [ + 'com.other.caplet', + ]); + }); + + it('logs uninstallation progress', async () => { + vi.mocked(mockStorage.get).mockImplementation(async (key: string) => { + if (key === 'com.example.test.subclusterId') { + return 'subcluster-123'; + } + if (key === 'installed') { + return ['com.example.test']; + } + return undefined; + }); + + const controller = makeCapletController(config, deps); + await controller.uninstall('com.example.test'); + + expect(mockLogger.info).toHaveBeenCalledWith( + 'Uninstalling caplet: com.example.test', + ); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Caplet com.example.test uninstalled', + ); + }); + }); + + describe('list', () => { + it('returns empty array when no caplets installed', async () => { + const controller = makeCapletController(config, deps); + const result = await controller.list(); + + expect(result).toStrictEqual([]); + }); + + it('returns all installed caplets', async () => { + const manifest2: CapletManifest = { + ...validManifest, + id: 'com.example.test2', + name: 'Test Caplet 2', + }; + + vi.mocked(mockStorage.get).mockImplementation(async (key: string) => { + if (key === 'installed') { + return ['com.example.test', 'com.example.test2']; + } + if (key === 'com.example.test.manifest') { + return validManifest; + } + if (key === 'com.example.test.subclusterId') { + return 'subcluster-1'; + } + if (key === 'com.example.test.installedAt') { + return 1000; + } + if (key === 'com.example.test2.manifest') { + return manifest2; + } + if (key === 'com.example.test2.subclusterId') { + return 'subcluster-2'; + } + if (key === 'com.example.test2.installedAt') { + return 2000; + } + return undefined; + }); + + const controller = makeCapletController(config, deps); + const result = await controller.list(); + + expect(result).toHaveLength(2); + expect(result[0]).toStrictEqual({ + manifest: validManifest, + subclusterId: 'subcluster-1', + installedAt: 1000, + }); + expect(result[1]).toStrictEqual({ + manifest: manifest2, + subclusterId: 'subcluster-2', + installedAt: 2000, + }); + }); + + it('skips caplets with missing data', async () => { + vi.mocked(mockStorage.get).mockImplementation(async (key: string) => { + if (key === 'installed') { + return ['com.example.test', 'com.example.missing']; + } + if (key === 'com.example.test.manifest') { + return validManifest; + } + if (key === 'com.example.test.subclusterId') { + return 'subcluster-1'; + } + if (key === 'com.example.test.installedAt') { + return 1000; + } + // com.example.missing has no data + return undefined; + }); + + const controller = makeCapletController(config, deps); + const result = await controller.list(); + + expect(result).toHaveLength(1); + expect(result[0]?.manifest.id).toBe('com.example.test'); + }); + }); + + describe('get', () => { + it('returns caplet if exists', async () => { + vi.mocked(mockStorage.get).mockImplementation(async (key: string) => { + if (key === 'com.example.test.manifest') { + return validManifest; + } + if (key === 'com.example.test.subclusterId') { + return 'subcluster-123'; + } + if (key === 'com.example.test.installedAt') { + return 1705320000000; + } + return undefined; + }); + + const controller = makeCapletController(config, deps); + const result = await controller.get('com.example.test'); + + expect(result).toStrictEqual({ + manifest: validManifest, + subclusterId: 'subcluster-123', + installedAt: 1705320000000, + }); + }); + + it('returns undefined if caplet not found', async () => { + const controller = makeCapletController(config, deps); + const result = await controller.get('com.example.notfound'); + + expect(result).toBeUndefined(); + }); + + it('returns undefined and logs warning if storage data corrupted', async () => { + vi.mocked(mockStorage.get).mockImplementation(async (key: string) => { + if (key === 'com.example.test.manifest') { + return validManifest; + } + // Missing subclusterId and installedAt + return undefined; + }); + + const controller = makeCapletController(config, deps); + const result = await controller.get('com.example.test'); + + expect(result).toBeUndefined(); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Caplet com.example.test has corrupted storage data', + ); + }); + }); + + describe('getByService', () => { + it('returns caplet providing the service', async () => { + vi.mocked(mockStorage.get).mockImplementation(async (key: string) => { + if (key === 'installed') { + return ['com.example.test']; + } + if (key === 'com.example.test.manifest') { + return validManifest; // providedServices: ['signer'] + } + if (key === 'com.example.test.subclusterId') { + return 'subcluster-123'; + } + if (key === 'com.example.test.installedAt') { + return 1000; + } + return undefined; + }); + + const controller = makeCapletController(config, deps); + const result = await controller.getByService('signer'); + + expect(result).toBeDefined(); + expect(result?.manifest.id).toBe('com.example.test'); + }); + + it('returns undefined if no caplet provides the service', async () => { + vi.mocked(mockStorage.get).mockImplementation(async (key: string) => { + if (key === 'installed') { + return ['com.example.test']; + } + if (key === 'com.example.test.manifest') { + return validManifest; + } + if (key === 'com.example.test.subclusterId') { + return 'subcluster-123'; + } + if (key === 'com.example.test.installedAt') { + return 1000; + } + return undefined; + }); + + const controller = makeCapletController(config, deps); + const result = await controller.getByService('unknown-service'); + + expect(result).toBeUndefined(); + }); + + it('returns first matching caplet when multiple provide the service', async () => { + const manifest2: CapletManifest = { + ...validManifest, + id: 'com.example.test2', + name: 'Test Caplet 2', + providedServices: ['signer', 'verifier'], + }; + + vi.mocked(mockStorage.get).mockImplementation(async (key: string) => { + if (key === 'installed') { + return ['com.example.test', 'com.example.test2']; + } + if (key === 'com.example.test.manifest') { + return validManifest; + } + if (key === 'com.example.test.subclusterId') { + return 'subcluster-1'; + } + if (key === 'com.example.test.installedAt') { + return 1000; + } + if (key === 'com.example.test2.manifest') { + return manifest2; + } + if (key === 'com.example.test2.subclusterId') { + return 'subcluster-2'; + } + if (key === 'com.example.test2.installedAt') { + return 2000; + } + return undefined; + }); + + const controller = makeCapletController(config, deps); + const result = await controller.getByService('signer'); + + // Returns first match + expect(result?.manifest.id).toBe('com.example.test'); + }); + }); +}); diff --git a/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts new file mode 100644 index 000000000..3a4ed0ed0 --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts @@ -0,0 +1,298 @@ +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; +import type { ClusterConfig } from '@metamask/ocap-kernel'; + +import type { + CapletId, + CapletManifest, + InstalledCaplet, + InstallResult, + LaunchResult, +} from './types.ts'; +import { isCapletManifest } from './types.ts'; +import type { NamespacedStorage } from '../storage/types.ts'; +import type { ControllerConfig } from '../types.ts'; + +/** + * Storage keys used by the CapletController within its namespace. + */ +const STORAGE_KEYS = { + /** List of installed caplet IDs */ + INSTALLED_LIST: 'installed', + /** Suffix for manifest storage: `${capletId}.manifest` */ + MANIFEST_SUFFIX: '.manifest', + /** Suffix for subclusterId storage: `${capletId}.subclusterId` */ + SUBCLUSTER_SUFFIX: '.subclusterId', + /** Suffix for installedAt storage: `${capletId}.installedAt` */ + INSTALLED_AT_SUFFIX: '.installedAt', +} as const; + +/** + * Generate storage key for a caplet's manifest. + * + * @param capletId - The caplet ID. + * @returns The storage key. + */ +const manifestKey = (capletId: CapletId): string => + `${capletId}${STORAGE_KEYS.MANIFEST_SUFFIX}`; + +/** + * Generate storage key for a caplet's subclusterId. + * + * @param capletId - The caplet ID. + * @returns The storage key. + */ +const subclusterKey = (capletId: CapletId): string => + `${capletId}${STORAGE_KEYS.SUBCLUSTER_SUFFIX}`; + +/** + * Generate storage key for a caplet's installedAt timestamp. + * + * @param capletId - The caplet ID. + * @returns The storage key. + */ +const installedAtKey = (capletId: CapletId): string => + `${capletId}${STORAGE_KEYS.INSTALLED_AT_SUFFIX}`; + +/** + * Methods exposed by the CapletController. + */ +export type CapletControllerMethods = { + /** + * Install a caplet. + * + * @param manifest - The caplet manifest. + * @param _bundle - The caplet bundle (currently unused, bundle loaded from bundleSpec). + * @returns The installation result. + */ + install: ( + manifest: CapletManifest, + _bundle?: unknown, + ) => Promise; + + /** + * Uninstall a caplet. + * + * @param capletId - The ID of the caplet to uninstall. + */ + uninstall: (capletId: CapletId) => Promise; + + /** + * List all installed caplets. + * + * @returns Array of installed caplets. + */ + list: () => Promise; + + /** + * Get a specific installed caplet. + * + * @param capletId - The caplet ID. + * @returns The installed caplet or undefined if not found. + */ + get: (capletId: CapletId) => Promise; + + /** + * Find a caplet that provides a specific service. + * + * @param serviceName - The service name to search for. + * @returns The installed caplet or undefined if not found. + */ + getByService: (serviceName: string) => Promise; +}; + +/** + * Dependencies for the CapletController. + * These are attenuated - only the methods needed are provided. + */ +export type CapletControllerDeps = { + /** Namespaced storage for caplet data */ + storage: NamespacedStorage; + /** Launch a subcluster for a caplet */ + launchSubcluster: (config: ClusterConfig) => Promise; + /** Terminate a caplet's subcluster */ + terminateSubcluster: (subclusterId: string) => Promise; +}; + +/** + * Create the CapletController. + * + * The CapletController manages the lifecycle of installed caplets: + * - Installing caplets (validating manifest, launching subcluster, storing metadata) + * - Uninstalling caplets (terminating subcluster, removing metadata) + * - Querying installed caplets + * + * @param config - Controller configuration. + * @param deps - Controller dependencies (attenuated for POLA). + * @returns A hardened CapletController exo. + */ +export function makeCapletController( + config: ControllerConfig, + deps: CapletControllerDeps, +): CapletControllerMethods { + const { logger } = config; + const { storage, launchSubcluster, terminateSubcluster } = deps; + + /** + * Get the list of installed caplet IDs. + * + * @returns Array of installed caplet IDs. + */ + const getInstalledIds = async (): Promise => { + const ids = await storage.get(STORAGE_KEYS.INSTALLED_LIST); + return ids ?? []; + }; + + /** + * Update the list of installed caplet IDs. + * + * @param ids - The list of caplet IDs to store. + */ + const setInstalledIds = async (ids: CapletId[]): Promise => { + await storage.set(STORAGE_KEYS.INSTALLED_LIST, ids); + }; + + /** + * Internal get implementation (to avoid `this` binding issues in exo). + * + * @param capletId - The caplet ID to retrieve. + * @returns The installed caplet or undefined if not found. + */ + const getCaplet = async ( + capletId: CapletId, + ): Promise => { + const manifest = await storage.get(manifestKey(capletId)); + if (manifest === undefined) { + return undefined; + } + + const [subclusterId, installedAt] = await Promise.all([ + storage.get(subclusterKey(capletId)), + storage.get(installedAtKey(capletId)), + ]); + + if (subclusterId === undefined || installedAt === undefined) { + // Corrupted data - manifest exists but other fields don't + logger.warn(`Caplet ${capletId} has corrupted storage data`); + return undefined; + } + + return { + manifest, + subclusterId, + installedAt, + }; + }; + + /** + * Internal list implementation (to avoid `this` binding issues in exo). + * + * @returns Array of all installed caplets. + */ + const listCaplets = async (): Promise => { + const installedIds = await getInstalledIds(); + const caplets: InstalledCaplet[] = []; + + for (const id of installedIds) { + const caplet = await getCaplet(id); + if (caplet !== undefined) { + caplets.push(caplet); + } + } + + return caplets; + }; + + return makeDefaultExo('CapletController', { + async install( + manifest: CapletManifest, + _bundle?: unknown, + ): Promise { + const { id } = manifest; + logger.info(`Installing caplet: ${id}`); + + // Validate manifest + if (!isCapletManifest(manifest)) { + throw new Error(`Invalid caplet manifest for ${id}`); + } + + // Check if already installed + const existing = await storage.get(manifestKey(id)); + if (existing !== undefined) { + throw new Error(`Caplet ${id} is already installed`); + } + + // Create cluster config for this caplet + const clusterConfig: ClusterConfig = { + bootstrap: id, + vats: { + [id]: { + bundleSpec: manifest.bundleSpec, + }, + }, + }; + + // Launch subcluster + const { subclusterId } = await launchSubcluster(clusterConfig); + + // Store caplet data + const now = Date.now(); + await Promise.all([ + storage.set(manifestKey(id), manifest), + storage.set(subclusterKey(id), subclusterId), + storage.set(installedAtKey(id), now), + ]); + + // Update installed list + const installedIds = await getInstalledIds(); + if (!installedIds.includes(id)) { + await setInstalledIds([...installedIds, id]); + } + + logger.info(`Caplet ${id} installed with subcluster ${subclusterId}`); + return { capletId: id, subclusterId }; + }, + + async uninstall(capletId: CapletId): Promise { + logger.info(`Uninstalling caplet: ${capletId}`); + + const subclusterId = await storage.get(subclusterKey(capletId)); + if (subclusterId === undefined) { + throw new Error(`Caplet ${capletId} not found`); + } + + // Terminate the subcluster + await terminateSubcluster(subclusterId); + + // Remove from storage + await Promise.all([ + storage.delete(manifestKey(capletId)), + storage.delete(subclusterKey(capletId)), + storage.delete(installedAtKey(capletId)), + ]); + + // Update installed list + const installedIds = await getInstalledIds(); + await setInstalledIds(installedIds.filter((id) => id !== capletId)); + + logger.info(`Caplet ${capletId} uninstalled`); + }, + + async list(): Promise { + return listCaplets(); + }, + + async get(capletId: CapletId): Promise { + return getCaplet(capletId); + }, + + async getByService( + serviceName: string, + ): Promise { + const caplets = await listCaplets(); + return caplets.find((caplet: InstalledCaplet) => + caplet.manifest.providedServices.includes(serviceName), + ); + }, + }); +} +harden(makeCapletController); diff --git a/packages/omnium-gatherum/src/controllers/caplet/index.ts b/packages/omnium-gatherum/src/controllers/caplet/index.ts new file mode 100644 index 000000000..e2b30889c --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/caplet/index.ts @@ -0,0 +1,22 @@ +export type { + CapletId, + SemVer, + CapletManifest, + InstalledCaplet, + InstallResult, + LaunchResult, +} from './types.ts'; +export { + isCapletId, + isSemVer, + isCapletManifest, + assertCapletManifest, + CapletIdStruct, + SemVerStruct, + CapletManifestStruct, +} from './types.ts'; +export type { + CapletControllerMethods, + CapletControllerDeps, +} from './caplet-controller.ts'; +export { makeCapletController } from './caplet-controller.ts'; diff --git a/packages/omnium-gatherum/src/controllers/caplet/types.test.ts b/packages/omnium-gatherum/src/controllers/caplet/types.test.ts new file mode 100644 index 000000000..2b1138f5f --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/caplet/types.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from 'vitest'; + +import { + isCapletId, + isSemVer, + isCapletManifest, + assertCapletManifest, +} from './types.ts'; + +describe('isCapletId', () => { + it.each([ + ['com.example.test', true], + ['org.metamask.keyring', true], + ['io.github.user.package', true], + ['a.b', true], + ['a1.b2', true], + ['test.caplet123', true], + ])('validates "%s" as %s', (value, expected) => { + expect(isCapletId(value)).toBe(expected); + }); + + it.each([ + ['', false], + ['single', false], // Must have at least 2 segments + ['com.Example.test', false], // No uppercase + ['com.123.test', false], // Segments cannot start with number + ['com..test', false], // Empty segment + ['com.test-name', false], // No hyphens + ['com.test_name', false], // No underscores + ['.com.test', false], // Cannot start with dot + ['com.test.', false], // Cannot end with dot + [123, false], // Not a string + [null, false], + [undefined, false], + [{}, false], + ])('rejects %s', (value, expected) => { + expect(isCapletId(value)).toBe(expected); + }); +}); + +describe('isSemVer', () => { + it.each([ + ['1.0.0', true], + ['0.0.1', true], + ['10.20.30', true], + ['1.0.0-alpha', true], + ['1.0.0-alpha.1', true], + ['0.0.0', true], + ['999.999.999', true], + ['1.2.3-0', true], + ])('validates "%s" as %s', (value, expected) => { + expect(isSemVer(value)).toBe(expected); + }); + + it.each([ + ['1.0', false], + ['1', false], + ['v1.0.0', false], // No 'v' prefix + ['1.0.0.0', false], + ['', false], + ['not-a-version', false], + ['1.0.0+build.123', false], // Build metadata not supported (semver strips it) + ['1.0.0-beta+build', false], // Build metadata not supported + [123, false], + [null, false], + [undefined, false], + ])('rejects %s', (value, expected) => { + expect(isSemVer(value)).toBe(expected); + }); +}); + +describe('isCapletManifest', () => { + const validManifest = { + id: 'com.example.test', + name: 'Test Caplet', + version: '1.0.0', + bundleSpec: 'https://example.com/bundle.json', + requestedServices: ['keyring'], + providedServices: ['signer'], + }; + + it('validates a complete manifest', () => { + expect(isCapletManifest(validManifest)).toBe(true); + }); + + it('validates a manifest with empty service arrays', () => { + const manifest = { + ...validManifest, + requestedServices: [], + providedServices: [], + }; + expect(isCapletManifest(manifest)).toBe(true); + }); + + it('rejects manifest with invalid id', () => { + expect(isCapletManifest({ ...validManifest, id: 'invalid' })).toBe(false); + }); + + it('rejects manifest with invalid version', () => { + expect(isCapletManifest({ ...validManifest, version: '1.0' })).toBe(false); + }); + + it('rejects manifest missing required field', () => { + const { name: _name, ...missingName } = validManifest; + expect(isCapletManifest(missingName)).toBe(false); + }); + + it('rejects null', () => { + expect(isCapletManifest(null)).toBe(false); + }); + + it('rejects non-object', () => { + expect(isCapletManifest('string')).toBe(false); + }); +}); + +describe('assertCapletManifest', () => { + const validManifest = { + id: 'com.example.test', + name: 'Test Caplet', + version: '1.0.0', + bundleSpec: 'https://example.com/bundle.json', + requestedServices: [], + providedServices: [], + }; + + it('does not throw for valid manifest', () => { + expect(() => assertCapletManifest(validManifest)).not.toThrow(); + }); + + it('throws for invalid manifest', () => { + expect(() => assertCapletManifest({ id: 'bad' })).toThrow( + 'Invalid CapletManifest', + ); + }); + + it('throws for null', () => { + expect(() => assertCapletManifest(null)).toThrow('Invalid CapletManifest'); + }); +}); diff --git a/packages/omnium-gatherum/src/controllers/caplet/types.ts b/packages/omnium-gatherum/src/controllers/caplet/types.ts new file mode 100644 index 000000000..cdf201be7 --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/caplet/types.ts @@ -0,0 +1,108 @@ +import { array, define, is, object, string } from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; +import semverValid from 'semver/functions/valid'; + +/** + * Unique identifier for a Caplet. + * Uses reverse domain notation (e.g., "com.example.bitcoin-signer"). + */ +export type CapletId = string; + +/** + * Validate CapletId format. + * Requires lowercase alphanumeric segments separated by dots, minimum 2 segments. + * + * @param value - The value to validate. + * @returns True if valid CapletId format. + */ +export const isCapletId = (value: unknown): value is CapletId => + typeof value === 'string' && + value.length > 0 && + /^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$/u.test(value); + +export const CapletIdStruct = define('CapletId', isCapletId); + +/** + * Semantic version string (e.g., "1.0.0"). + */ +export type SemVer = string; + +/** + * Validate SemVer format using the semver package. + * Requires strict format without 'v' prefix (e.g., "1.0.0" not "v1.0.0"). + * + * @param value - The value to validate. + * @returns True if valid SemVer format. + */ +export const isSemVer = (value: unknown): value is SemVer => + typeof value === 'string' && + // semver.valid() is lenient and strips 'v' prefix, so check that cleaned value equals original + semverValid(value) === value; + +export const SemVerStruct = define('SemVer', isSemVer); + +/** + * Superstruct schema for validating CapletManifest objects. + */ +export const CapletManifestStruct = object({ + id: CapletIdStruct, + name: string(), + version: SemVerStruct, + bundleSpec: string(), + requestedServices: array(string()), + providedServices: array(string()), +}); + +/** + * Metadata that defines a Caplet's identity, dependencies, and capabilities. + */ +export type CapletManifest = Infer; + +/** + * Type guard for CapletManifest validation. + * + * @param value - The value to validate. + * @returns True if the value is a valid CapletManifest. + */ +export const isCapletManifest = (value: unknown): value is CapletManifest => + is(value, CapletManifestStruct); + +/** + * Assert that a value is a valid CapletManifest. + * + * @param value - The value to validate. + * @throws If the value is not a valid CapletManifest. + */ +export function assertCapletManifest( + value: unknown, +): asserts value is CapletManifest { + if (!isCapletManifest(value)) { + throw new Error('Invalid CapletManifest'); + } +} + +/** + * Record for an installed Caplet. + * Combines manifest with runtime identifiers. + */ +export type InstalledCaplet = { + manifest: CapletManifest; + subclusterId: string; + installedAt: number; +}; + +/** + * Result of installing a Caplet. + */ +export type InstallResult = { + capletId: CapletId; + subclusterId: string; +}; + +/** + * Result of launching a subcluster. + * This is the interface expected by CapletController's deps. + */ +export type LaunchResult = { + subclusterId: string; +}; diff --git a/packages/omnium-gatherum/src/controllers/facet.test.ts b/packages/omnium-gatherum/src/controllers/facet.test.ts new file mode 100644 index 000000000..7cb784897 --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/facet.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { makeFacet } from './facet.ts'; + +describe('makeFacet', () => { + const makeSourceObject = () => ({ + method1: vi.fn().mockReturnValue('result1'), + method2: vi.fn().mockReturnValue('result2'), + method3: vi.fn().mockReturnValue('result3'), + asyncMethod: vi.fn().mockResolvedValue('asyncResult'), + }); + + it('creates a facet with only specified methods', () => { + const source = makeSourceObject(); + + const facet = makeFacet('TestFacet', source, ['method1', 'method2']); + + expect(facet.method1).toBeDefined(); + expect(facet.method2).toBeDefined(); + expect((facet as Record).method3).toBeUndefined(); + expect((facet as Record).asyncMethod).toBeUndefined(); + }); + + it('facet methods call the source methods', () => { + const source = makeSourceObject(); + + const facet = makeFacet('TestFacet', source, ['method1']); + facet.method1(); + + expect(source.method1).toHaveBeenCalledOnce(); + }); + + it('facet methods return the same result as source', () => { + const source = makeSourceObject(); + + const facet = makeFacet('TestFacet', source, ['method1']); + const result = facet.method1(); + + expect(result).toBe('result1'); + }); + + it('facet methods pass arguments to source', () => { + const source = makeSourceObject(); + + const facet = makeFacet('TestFacet', source, ['method1']); + facet.method1('arg1', 'arg2'); + + expect(source.method1).toHaveBeenCalledWith('arg1', 'arg2'); + }); + + it('works with async methods', async () => { + const source = makeSourceObject(); + + const facet = makeFacet('TestFacet', source, ['asyncMethod']); + const result = await facet.asyncMethod(); + + expect(result).toBe('asyncResult'); + expect(source.asyncMethod).toHaveBeenCalledOnce(); + }); + + it('creates facet with single method', () => { + const source = makeSourceObject(); + + const facet = makeFacet('SingleMethodFacet', source, ['method1']); + + expect(facet.method1).toBeDefined(); + // Verify only the specified method is accessible + expect((facet as Record).method2).toBeUndefined(); + expect((facet as Record).method3).toBeUndefined(); + }); + + it('creates facet with all methods', () => { + const source = makeSourceObject(); + + const facet = makeFacet('AllMethodsFacet', source, [ + 'method1', + 'method2', + 'method3', + 'asyncMethod', + ]); + + expect(facet.method1).toBeDefined(); + expect(facet.method2).toBeDefined(); + expect(facet.method3).toBeDefined(); + expect(facet.asyncMethod).toBeDefined(); + }); + + it('throws when method does not exist on source', () => { + const source = makeSourceObject(); + + expect(() => + makeFacet('TestFacet', source, ['nonExistent' as keyof typeof source]), + ).toThrow( + "makeFacet: Method 'nonExistent' not found on source or is not a function", + ); + }); + + it('throws when property is not a function', () => { + const source = { + method1: vi.fn(), + notAMethod: 'string value', + }; + + expect(() => + // @ts-expect-error Destructive testing + makeFacet('TestFacet', source, ['notAMethod' as keyof typeof source]), + ).toThrow( + "makeFacet: Method 'notAMethod' not found on source or is not a function", + ); + }); + + it('preserves this context when methods use it', () => { + const source = { + value: 42, + getValue(this: { value: number }): number { + return this.value; + }, + }; + + const facet = makeFacet('TestFacet', source, ['getValue']); + const result = facet.getValue(); + + expect(result).toBe(42); + }); +}); diff --git a/packages/omnium-gatherum/src/controllers/facet.ts b/packages/omnium-gatherum/src/controllers/facet.ts new file mode 100644 index 000000000..95e8a1338 --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/facet.ts @@ -0,0 +1,70 @@ +import type { Methods, MethodGuard } from '@endo/exo'; +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; + +// RemotableMethodName from @endo/pass-style is string | symbol +type MethodKeys = Extract< + { + [Key in keyof Source]: Source[Key] extends CallableFunction ? Key : never; + }[keyof Source], + keyof MethodGuard +>; + +type BoundMethod = Func extends CallableFunction + ? OmitThisParameter + : never; + +type FacetMethods> = Methods & { + [Key in MethodNames]: BoundMethod; +}; + +/** + * Create an attenuated facet of a source object that exposes only specific methods. + * + * This enforces POLA (Principle of Least Authority) by allowing Controller A + * to receive only the methods it needs from Controller B. + * + * @param name - Name for the facet (used in debugging/logging). + * @param source - The source object containing methods. + * @param methodNames - Array of method names to expose. + * @returns A hardened facet exo with only the specified methods. + * @example + * ```typescript + * // StorageController exposes full interface internally + * const storageController = makeStorageController(config); + * + * // CapletController only needs get/set, not clear/getAll + * const storageFacet = makeFacet('CapletStorage', storageController, ['get', 'set']); + * const capletController = makeCapletController({ storage: storageFacet }); + * ``` + */ +export function makeFacet< + Source extends Record, + MethodNames extends MethodKeys, +>( + name: string, + source: Source, + methodNames: readonly MethodNames[], +): FacetMethods { + const methods: Partial> = {}; + + for (const methodName of methodNames) { + const method = source[methodName]; + if (typeof method !== 'function') { + throw new Error( + `makeFacet: Method '${String( + methodName, + )}' not found on source or is not a function`, + ); + } + // Bind the method to preserve 'this' context if needed + methods[methodName] = (method as CallableFunction).bind( + source, + ) as BoundMethod as FacetMethods< + Source, + MethodNames + >[MethodNames]; + } + + return makeDefaultExo(name, methods as FacetMethods); +} +harden(makeFacet); diff --git a/packages/omnium-gatherum/src/controllers/index.ts b/packages/omnium-gatherum/src/controllers/index.ts new file mode 100644 index 000000000..25b792d4b --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/index.ts @@ -0,0 +1,32 @@ +// Base types +export type { ControllerConfig, FacetOf } from './types.ts'; +export { makeFacet } from './facet.ts'; + +// Storage +export type { NamespacedStorage, StorageAdapter } from './storage/index.ts'; +export { + makeChromeStorageAdapter, + makeNamespacedStorage, +} from './storage/index.ts'; + +// Caplet +export type { + CapletId, + SemVer, + CapletManifest, + InstalledCaplet, + InstallResult, + LaunchResult, + CapletControllerMethods, + CapletControllerDeps, +} from './caplet/index.ts'; +export { + isCapletId, + isSemVer, + isCapletManifest, + assertCapletManifest, + CapletIdStruct, + SemVerStruct, + CapletManifestStruct, + makeCapletController, +} from './caplet/index.ts'; diff --git a/packages/omnium-gatherum/src/controllers/storage/chrome-storage.test.ts b/packages/omnium-gatherum/src/controllers/storage/chrome-storage.test.ts new file mode 100644 index 000000000..403fe7dcb --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/storage/chrome-storage.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { makeChromeStorageAdapter } from './chrome-storage.ts'; + +describe('makeChromeStorageAdapter', () => { + const mockStorage = { + get: vi.fn().mockResolvedValue({}), + set: vi.fn(), + remove: vi.fn(), + }; + + beforeEach(() => { + mockStorage.get.mockResolvedValue({}); + }); + + describe('get', () => { + it('returns value for existing key', async () => { + mockStorage.get.mockResolvedValue({ testKey: 'testValue' }); + + const adapter = makeChromeStorageAdapter( + mockStorage as unknown as chrome.storage.StorageArea, + ); + const result = await adapter.get('testKey'); + + expect(result).toBe('testValue'); + expect(mockStorage.get).toHaveBeenCalledWith('testKey'); + }); + + it('returns undefined for non-existent key', async () => { + mockStorage.get.mockResolvedValue({}); + + const adapter = makeChromeStorageAdapter( + mockStorage as unknown as chrome.storage.StorageArea, + ); + const result = await adapter.get('nonExistent'); + + expect(result).toBeUndefined(); + }); + + it('returns complex objects', async () => { + const complexValue = { nested: { data: [1, 2, 3] } }; + mockStorage.get.mockResolvedValue({ complex: complexValue }); + + const adapter = makeChromeStorageAdapter( + mockStorage as unknown as chrome.storage.StorageArea, + ); + const result = await adapter.get('complex'); + + expect(result).toStrictEqual(complexValue); + }); + }); + + describe('set', () => { + it('sets a value', async () => { + const adapter = makeChromeStorageAdapter( + mockStorage as unknown as chrome.storage.StorageArea, + ); + await adapter.set('key', 'value'); + + expect(mockStorage.set).toHaveBeenCalledWith({ key: 'value' }); + }); + + it('sets complex objects', async () => { + const complexValue = { nested: { data: [1, 2, 3] } }; + + const adapter = makeChromeStorageAdapter( + mockStorage as unknown as chrome.storage.StorageArea, + ); + await adapter.set('complex', complexValue); + + expect(mockStorage.set).toHaveBeenCalledWith({ complex: complexValue }); + }); + }); + + describe('delete', () => { + it('deletes a key', async () => { + const adapter = makeChromeStorageAdapter( + mockStorage as unknown as chrome.storage.StorageArea, + ); + await adapter.delete('keyToDelete'); + + expect(mockStorage.remove).toHaveBeenCalledWith('keyToDelete'); + }); + }); + + describe('keys', () => { + it('returns all keys when no prefix provided', async () => { + mockStorage.get.mockResolvedValue({ + key1: 'value1', + key2: 'value2', + other: 'value3', + }); + + const adapter = makeChromeStorageAdapter( + mockStorage as unknown as chrome.storage.StorageArea, + ); + const result = await adapter.keys(); + + expect(result).toStrictEqual(['key1', 'key2', 'other']); + expect(mockStorage.get).toHaveBeenCalledWith(null); + }); + + it('filters keys by prefix', async () => { + mockStorage.get.mockResolvedValue({ + 'prefix.key1': 'value1', + 'prefix.key2': 'value2', + other: 'value3', + }); + + const adapter = makeChromeStorageAdapter( + mockStorage as unknown as chrome.storage.StorageArea, + ); + const result = await adapter.keys('prefix.'); + + expect(result).toStrictEqual(['prefix.key1', 'prefix.key2']); + }); + + it('returns empty array when no keys match prefix', async () => { + mockStorage.get.mockResolvedValue({ + key1: 'value1', + key2: 'value2', + }); + + const adapter = makeChromeStorageAdapter( + mockStorage as unknown as chrome.storage.StorageArea, + ); + const result = await adapter.keys('nonexistent.'); + + expect(result).toStrictEqual([]); + }); + }); +}); diff --git a/packages/omnium-gatherum/src/controllers/storage/chrome-storage.ts b/packages/omnium-gatherum/src/controllers/storage/chrome-storage.ts new file mode 100644 index 000000000..4c0134757 --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/storage/chrome-storage.ts @@ -0,0 +1,38 @@ +import type { Json } from '@metamask/utils'; + +import type { StorageAdapter } from './types.ts'; + +/** + * Create a storage adapter backed by Chrome Storage API. + * + * @param storage - The Chrome storage area to use (defaults to chrome.storage.local). + * @returns A hardened StorageAdapter instance. + */ +export function makeChromeStorageAdapter( + storage: chrome.storage.StorageArea = chrome.storage.local, +): StorageAdapter { + return harden({ + async get(key: string): Promise { + const result = await storage.get(key); + return result[key] as Value | undefined; + }, + + async set(key: string, value: Json): Promise { + await storage.set({ [key]: value }); + }, + + async delete(key: string): Promise { + await storage.remove(key); + }, + + async keys(prefix?: string): Promise { + const all = await storage.get(null); + const allKeys = Object.keys(all); + if (prefix === undefined) { + return allKeys; + } + return allKeys.filter((k) => k.startsWith(prefix)); + }, + }); +} +harden(makeChromeStorageAdapter); diff --git a/packages/omnium-gatherum/src/controllers/storage/index.ts b/packages/omnium-gatherum/src/controllers/storage/index.ts new file mode 100644 index 000000000..5d9628d33 --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/storage/index.ts @@ -0,0 +1,3 @@ +export type { NamespacedStorage, StorageAdapter } from './types.ts'; +export { makeChromeStorageAdapter } from './chrome-storage.ts'; +export { makeNamespacedStorage } from './namespaced-storage.ts'; diff --git a/packages/omnium-gatherum/src/controllers/storage/namespaced-storage.test.ts b/packages/omnium-gatherum/src/controllers/storage/namespaced-storage.test.ts new file mode 100644 index 000000000..b427b63fe --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/storage/namespaced-storage.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { makeNamespacedStorage } from './namespaced-storage.ts'; +import type { StorageAdapter } from './types.ts'; + +describe('makeNamespacedStorage', () => { + const mockAdapter: StorageAdapter = { + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), + keys: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(mockAdapter.get).mockResolvedValue(undefined); + vi.mocked(mockAdapter.set).mockResolvedValue(undefined); + vi.mocked(mockAdapter.delete).mockResolvedValue(undefined); + vi.mocked(mockAdapter.keys).mockResolvedValue([]); + }); + + describe('get', () => { + it('prefixes key with namespace', async () => { + vi.mocked(mockAdapter.get).mockResolvedValue('value'); + + const storage = makeNamespacedStorage('caplet', mockAdapter); + const result = await storage.get('myKey'); + + expect(result).toBe('value'); + expect(mockAdapter.get).toHaveBeenCalledWith('caplet.myKey'); + }); + + it('returns undefined for non-existent key', async () => { + vi.mocked(mockAdapter.get).mockResolvedValue(undefined); + + const storage = makeNamespacedStorage('caplet', mockAdapter); + const result = await storage.get('nonExistent'); + + expect(result).toBeUndefined(); + }); + }); + + describe('set', () => { + it('prefixes key with namespace', async () => { + const storage = makeNamespacedStorage('caplet', mockAdapter); + await storage.set('myKey', 'myValue'); + + expect(mockAdapter.set).toHaveBeenCalledWith('caplet.myKey', 'myValue'); + }); + + it('handles complex values', async () => { + const complexValue = { nested: { data: [1, 2, 3] } }; + + const storage = makeNamespacedStorage('caplet', mockAdapter); + await storage.set('complex', complexValue); + + expect(mockAdapter.set).toHaveBeenCalledWith( + 'caplet.complex', + complexValue, + ); + }); + }); + + describe('delete', () => { + it('prefixes key with namespace', async () => { + const storage = makeNamespacedStorage('caplet', mockAdapter); + await storage.delete('myKey'); + + expect(mockAdapter.delete).toHaveBeenCalledWith('caplet.myKey'); + }); + }); + + describe('has', () => { + it('returns true when key exists', async () => { + vi.mocked(mockAdapter.get).mockResolvedValue('value'); + + const storage = makeNamespacedStorage('caplet', mockAdapter); + const result = await storage.has('myKey'); + + expect(result).toBe(true); + expect(mockAdapter.get).toHaveBeenCalledWith('caplet.myKey'); + }); + + it('returns false when key does not exist', async () => { + vi.mocked(mockAdapter.get).mockResolvedValue(undefined); + + const storage = makeNamespacedStorage('caplet', mockAdapter); + const result = await storage.has('nonExistent'); + + expect(result).toBe(false); + }); + }); + + describe('keys', () => { + it('returns keys with namespace prefix stripped', async () => { + vi.mocked(mockAdapter.keys).mockResolvedValue([ + 'caplet.key1', + 'caplet.key2', + 'caplet.nested.key', + ]); + + const storage = makeNamespacedStorage('caplet', mockAdapter); + const result = await storage.keys(); + + expect(result).toStrictEqual(['key1', 'key2', 'nested.key']); + expect(mockAdapter.keys).toHaveBeenCalledWith('caplet.'); + }); + + it('returns empty array when no keys in namespace', async () => { + vi.mocked(mockAdapter.keys).mockResolvedValue([]); + + const storage = makeNamespacedStorage('caplet', mockAdapter); + const result = await storage.keys(); + + expect(result).toStrictEqual([]); + }); + }); + + describe('clear', () => { + it('deletes all keys in namespace', async () => { + vi.mocked(mockAdapter.keys).mockResolvedValue([ + 'caplet.key1', + 'caplet.key2', + ]); + + const storage = makeNamespacedStorage('caplet', mockAdapter); + await storage.clear(); + + expect(mockAdapter.delete).toHaveBeenCalledTimes(2); + expect(mockAdapter.delete).toHaveBeenCalledWith('caplet.key1'); + expect(mockAdapter.delete).toHaveBeenCalledWith('caplet.key2'); + }); + + it('does nothing when namespace is empty', async () => { + vi.mocked(mockAdapter.keys).mockResolvedValue([]); + + const storage = makeNamespacedStorage('caplet', mockAdapter); + await storage.clear(); + + expect(mockAdapter.delete).not.toHaveBeenCalled(); + }); + }); + + describe('namespace isolation', () => { + it('uses different prefixes for different namespaces', async () => { + const storage1 = makeNamespacedStorage('caplet', mockAdapter); + const storage2 = makeNamespacedStorage('service', mockAdapter); + + await storage1.set('key', 'value1'); + await storage2.set('key', 'value2'); + + expect(mockAdapter.set).toHaveBeenCalledWith('caplet.key', 'value1'); + expect(mockAdapter.set).toHaveBeenCalledWith('service.key', 'value2'); + }); + }); +}); diff --git a/packages/omnium-gatherum/src/controllers/storage/namespaced-storage.ts b/packages/omnium-gatherum/src/controllers/storage/namespaced-storage.ts new file mode 100644 index 000000000..51e0c3eae --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/storage/namespaced-storage.ts @@ -0,0 +1,52 @@ +import type { Json } from '@metamask/utils'; + +import type { NamespacedStorage, StorageAdapter } from './types.ts'; + +/** + * Create a namespaced storage interface. + * All operations are scoped to the given namespace prefix. + * + * @param namespace - The namespace prefix for all keys. + * @param adapter - The underlying storage adapter. + * @returns A hardened NamespacedStorage instance. + */ +export function makeNamespacedStorage( + namespace: string, + adapter: StorageAdapter, +): NamespacedStorage { + const prefix = `${namespace}.`; + + const buildKey = (key: string): string => `${prefix}${key}`; + + const stripPrefix = (fullKey: string): string => fullKey.slice(prefix.length); + + return harden({ + async get(key: string): Promise { + return adapter.get(buildKey(key)); + }, + + async set(key: string, value: Json): Promise { + await adapter.set(buildKey(key), value); + }, + + async delete(key: string): Promise { + await adapter.delete(buildKey(key)); + }, + + async has(key: string): Promise { + const value = await adapter.get(buildKey(key)); + return value !== undefined; + }, + + async keys(): Promise { + const allKeys = await adapter.keys(prefix); + return allKeys.map(stripPrefix); + }, + + async clear(): Promise { + const allKeys = await this.keys(); + await Promise.all(allKeys.map(async (key) => this.delete(key))); + }, + }); +} +harden(makeNamespacedStorage); diff --git a/packages/omnium-gatherum/src/controllers/storage/types.ts b/packages/omnium-gatherum/src/controllers/storage/types.ts new file mode 100644 index 000000000..dab4a14a4 --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/storage/types.ts @@ -0,0 +1,88 @@ +import type { Json } from '@metamask/utils'; + +/** + * Low-level storage adapter interface. + * Wraps platform-specific storage APIs (e.g., chrome.storage.local). + */ +export type StorageAdapter = { + /** + * Get a value from storage. + * + * @param key - The storage key. + * @returns The stored value, or undefined if not found. + */ + get: (key: string) => Promise; + + /** + * Set a value in storage. + * + * @param key - The storage key. + * @param value - The value to store. + */ + set: (key: string, value: Json) => Promise; + + /** + * Delete a value from storage. + * + * @param key - The storage key. + */ + delete: (key: string) => Promise; + + /** + * Get all keys matching a prefix. + * + * @param prefix - Optional prefix to filter keys. + * @returns Array of matching keys. + */ + keys: (prefix?: string) => Promise; +}; + +/** + * Storage interface bound to a specific namespace. + * Controllers receive this instead of raw storage access. + * Keys are automatically prefixed with the namespace. + */ +export type NamespacedStorage = { + /** + * Get a value from the namespaced storage. + * + * @param key - The key within this namespace. + * @returns The stored value, or undefined if not found. + */ + get: (key: string) => Promise; + + /** + * Set a value in the namespaced storage. + * + * @param key - The key within this namespace. + * @param value - The value to store. + */ + set: (key: string, value: Json) => Promise; + + /** + * Delete a value from the namespaced storage. + * + * @param key - The key within this namespace. + */ + delete: (key: string) => Promise; + + /** + * Check if a key exists in the namespaced storage. + * + * @param key - The key within this namespace. + * @returns True if the key exists. + */ + has: (key: string) => Promise; + + /** + * Get all keys within this namespace. + * + * @returns Array of keys (without namespace prefix). + */ + keys: () => Promise; + + /** + * Clear all values in this namespace. + */ + clear: () => Promise; +}; diff --git a/packages/omnium-gatherum/src/controllers/types.ts b/packages/omnium-gatherum/src/controllers/types.ts new file mode 100644 index 000000000..2c4cfc890 --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/types.ts @@ -0,0 +1,24 @@ +import type { Methods } from '@endo/exo'; +import type { Logger } from '@metamask/logger'; + +/** + * Configuration passed to all controllers during initialization. + */ +export type ControllerConfig = { + logger: Logger; +}; + +/** + * Type helper for defining facet interfaces. + * Extracts a subset of methods from a controller type for POLA attenuation. + * + * @example + * ```typescript + * type StorageReadFacet = FacetOf; + * type StorageWriteFacet = FacetOf; + * ``` + */ +export type FacetOf< + TController extends Methods, + TMethodNames extends keyof TController, +> = Pick; diff --git a/packages/omnium-gatherum/src/global.d.ts b/packages/omnium-gatherum/src/global.d.ts index a275d71d9..7e1d58bf2 100644 --- a/packages/omnium-gatherum/src/global.d.ts +++ b/packages/omnium-gatherum/src/global.d.ts @@ -1,5 +1,11 @@ import type { KernelFacade } from '@metamask/kernel-browser-runtime'; +import type { + CapletManifest, + InstalledCaplet, + InstallResult, +} from './controllers/index.ts'; + // Type declarations for omnium dev console API. declare global { /** @@ -33,6 +39,66 @@ declare global { * ``` */ getKernel: () => Promise; + + /** + * Caplet management API. + */ + caplet: { + /** + * Install a caplet. + * + * @param manifest - The caplet manifest. + * @param bundle - Optional bundle (currently unused). + * @returns The installation result. + * @example + * ```typescript + * const result = await omnium.caplet.install({ + * id: 'com.example.test', + * name: 'Test Caplet', + * version: '1.0.0', + * bundleSpec: '/path/to/bundle.json', + * requestedServices: [], + * providedServices: ['test'], + * }); + * ``` + */ + install: ( + manifest: CapletManifest, + bundle?: unknown, + ) => Promise; + + /** + * Uninstall a caplet. + * + * @param capletId - The ID of the caplet to uninstall. + */ + uninstall: (capletId: string) => Promise; + + /** + * List all installed caplets. + * + * @returns Array of installed caplets. + */ + list: () => Promise; + + /** + * Get a specific installed caplet. + * + * @param capletId - The caplet ID. + * @returns The installed caplet or undefined if not found. + */ + get: (capletId: string) => Promise; + + /** + * Find a caplet that provides a specific service. + * + * @param serviceName - The service name to search for. + * @returns The installed caplet or undefined if not found. + */ + getByService: ( + serviceName: string, + ) => Promise; + }; }; } diff --git a/yarn.lock b/yarn.lock index 656153339..0e64e8f48 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3861,6 +3861,7 @@ __metadata: dependencies: "@arethetypeswrong/cli": "npm:^0.17.4" "@endo/eventual-send": "npm:^1.3.4" + "@endo/exo": "npm:^1.5.12" "@metamask/auto-changelog": "npm:^5.0.1" "@metamask/eslint-config": "npm:^14.0.0" "@metamask/eslint-config-nodejs": "npm:^14.0.0" @@ -3870,7 +3871,10 @@ __metadata: "@metamask/kernel-ui": "workspace:^" "@metamask/kernel-utils": "workspace:^" "@metamask/logger": "workspace:^" + "@metamask/ocap-kernel": "workspace:^" "@metamask/streams": "workspace:^" + "@metamask/superstruct": "npm:^3.2.1" + "@metamask/utils": "npm:^11.4.2" "@ocap/cli": "workspace:^" "@ocap/repo-tools": "workspace:^" "@playwright/test": "npm:^1.54.2" @@ -3900,6 +3904,7 @@ __metadata: react: "npm:^17.0.2" react-dom: "npm:^17.0.2" rimraf: "npm:^6.0.1" + semver: "npm:^7.7.1" ses: "npm:^1.14.0" tsx: "npm:^4.20.6" turbo: "npm:^2.5.6" From e400c93350393f894b76fce33e3c7d9c7deb0af8 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 8 Jan 2026 10:22:46 -0800 Subject: [PATCH 09/11] refactor(omnium): Simplify CapletController state structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate CapletControllerState from multiple top-level keys (installed, manifests, subclusters, installedAt) into a single `caplets: Record` structure. Changes: - Add ControllerStorage abstraction using Immer for state management - Controllers work with typed state object instead of storage keys - Only modified top-level keys are persisted (via Immer patches) - Remove state corruption checks (no longer possible with atomic storage) - Fix makeFacet type - use string | symbol instead of keyof MethodGuard - Update PLAN.md to reflect new storage architecture 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/omnium-gatherum/PLAN.md | 16 +- packages/omnium-gatherum/package.json | 1 + packages/omnium-gatherum/src/background.ts | 19 +- .../caplet/caplet-controller.test.ts | 543 +++++++++--------- .../controllers/caplet/caplet-controller.ts | 156 +---- .../src/controllers/caplet/index.ts | 1 + .../omnium-gatherum/src/controllers/facet.ts | 17 +- .../omnium-gatherum/src/controllers/index.ts | 9 +- .../storage/controller-storage.test.ts | 336 +++++++++++ .../controllers/storage/controller-storage.ts | 224 ++++++++ .../src/controllers/storage/index.ts | 5 + yarn.lock | 8 + 12 files changed, 932 insertions(+), 403 deletions(-) create mode 100644 packages/omnium-gatherum/src/controllers/storage/controller-storage.test.ts create mode 100644 packages/omnium-gatherum/src/controllers/storage/controller-storage.ts diff --git a/packages/omnium-gatherum/PLAN.md b/packages/omnium-gatherum/PLAN.md index caf22976e..fa746affc 100644 --- a/packages/omnium-gatherum/PLAN.md +++ b/packages/omnium-gatherum/PLAN.md @@ -106,7 +106,11 @@ capabilities. - `NamespacedStorage`: Scoped storage interface with automatic key prefixing - `controllers/storage/chrome-storage.ts`: `makeChromeStorageAdapter()` for Chrome Storage API - `controllers/storage/namespaced-storage.ts`: `makeNamespacedStorage()` factory - - Storage keys automatically prefixed: `${namespace}.${key}` (e.g., `caplet.com.example.test.manifest`) + - `controllers/storage/controller-storage.ts`: `makeControllerStorage()` for controller state management + - Controllers work with a typed `state` object instead of managing storage keys directly + - Uses Immer for immutable updates with change tracking + - Only persists modified top-level keys (via Immer patches) + - Storage keys automatically prefixed: `${namespace}.${key}` (e.g., `caplet.caplets`) - [x] **Caplet Manifest Schema** @@ -126,11 +130,11 @@ capabilities. - `list()`: Get all installed caplets - `get(capletId)`: Get specific caplet - `getByService(serviceName)`: Find caplet providing a service - - Storage keys (within `caplet` namespace): - - `installed`: Array of installed caplet IDs - - `${capletId}.manifest`: CapletManifest JSON - - `${capletId}.subclusterId`: Associated subcluster ID - - `${capletId}.installedAt`: Installation timestamp + - State structure (`CapletControllerState`): + - `caplets`: `Record` - all caplet data in a single record + - Uses `ControllerStorage` for state management + - Synchronous reads via `storage.state.caplets[id]` + - Async updates via `storage.update(draft => { ... })` - [x] **Dev Console Integration** diff --git a/packages/omnium-gatherum/package.json b/packages/omnium-gatherum/package.json index b37f2251c..205814f7d 100644 --- a/packages/omnium-gatherum/package.json +++ b/packages/omnium-gatherum/package.json @@ -54,6 +54,7 @@ "@metamask/streams": "workspace:^", "@metamask/superstruct": "^3.2.1", "@metamask/utils": "^11.4.2", + "immer": "^10.1.1", "react": "^17.0.2", "react-dom": "^17.0.2", "semver": "^7.7.1", diff --git a/packages/omnium-gatherum/src/background.ts b/packages/omnium-gatherum/src/background.ts index 658214bb9..f1d60fbcb 100644 --- a/packages/omnium-gatherum/src/background.ts +++ b/packages/omnium-gatherum/src/background.ts @@ -17,10 +17,14 @@ import { ChromeRuntimeDuplexStream } from '@metamask/streams/browser'; import { makeChromeStorageAdapter, - makeNamespacedStorage, + makeControllerStorage, makeCapletController, } from './controllers/index.ts'; -import type { CapletManifest, LaunchResult } from './controllers/index.ts'; +import type { + CapletControllerState, + CapletManifest, + LaunchResult, +} from './controllers/index.ts'; defineGlobals(); @@ -120,9 +124,16 @@ async function main(): Promise { return kernelP; }; - // Create storage adapter and namespaced storage for caplets + // Create storage adapter and state storage for caplets const storageAdapter = makeChromeStorageAdapter(); - const capletStorage = makeNamespacedStorage('caplet', storageAdapter); + const defaultCapletState: CapletControllerState = { + caplets: {}, + }; + const capletStorage = await makeControllerStorage({ + namespace: 'caplet', + adapter: storageAdapter, + defaultState: defaultCapletState, + }); // Create CapletController with attenuated kernel access const capletController = makeCapletController( diff --git a/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.test.ts b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.test.ts index 0da7d6b59..23b99df3a 100644 --- a/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.test.ts +++ b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.test.ts @@ -1,10 +1,53 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { makeCapletController } from './caplet-controller.ts'; +import type { CapletControllerState } from './caplet-controller.ts'; import type { CapletManifest } from './types.ts'; -import type { NamespacedStorage } from '../storage/types.ts'; +import type { ControllerStorage } from '../storage/controller-storage.ts'; import type { ControllerConfig } from '../types.ts'; +/** + * Create a mock ControllerStorage for testing. + * Maintains in-memory state and tracks update calls. + * + * @param initialState - The initial state for the mock storage. + * @returns A mock ControllerStorage instance with update tracking. + */ +function createMockStorage( + initialState: CapletControllerState, +): ControllerStorage & { updateCalls: (() => void)[] } { + let currentState = { ...initialState }; + const updateCalls: (() => void)[] = []; + + return { + get state(): Readonly { + return harden({ ...currentState }); + }, + + async update( + producer: (draft: CapletControllerState) => void, + ): Promise { + // Create a mutable draft + const draft = JSON.parse( + JSON.stringify(currentState), + ) as CapletControllerState; + producer(draft); + currentState = draft; + updateCalls.push(() => producer(draft)); + }, + + async reload(): Promise { + // No-op for tests + }, + + updateCalls, + }; +} + +const emptyState: CapletControllerState = { + caplets: {}, +}; + describe('makeCapletController', () => { const mockLogger = { info: vi.fn(), @@ -14,15 +57,6 @@ describe('makeCapletController', () => { subLogger: vi.fn().mockReturnThis(), }; - const mockStorage: NamespacedStorage = { - get: vi.fn(), - set: vi.fn(), - delete: vi.fn(), - has: vi.fn(), - keys: vi.fn(), - clear: vi.fn(), - }; - const mockLaunchSubcluster = vi.fn(); const mockTerminateSubcluster = vi.fn(); @@ -30,12 +64,6 @@ describe('makeCapletController', () => { logger: mockLogger as unknown as ControllerConfig['logger'], }; - const deps = { - storage: mockStorage, - launchSubcluster: mockLaunchSubcluster, - terminateSubcluster: mockTerminateSubcluster, - }; - const validManifest: CapletManifest = { id: 'com.example.test', name: 'Test Caplet', @@ -46,8 +74,7 @@ describe('makeCapletController', () => { }; beforeEach(() => { - vi.mocked(mockStorage.has).mockResolvedValue(false); - vi.mocked(mockStorage.keys).mockResolvedValue([]); + vi.clearAllMocks(); vi.mocked(mockLaunchSubcluster).mockResolvedValue({ subclusterId: 'subcluster-123', }); @@ -55,7 +82,13 @@ describe('makeCapletController', () => { describe('install', () => { it('installs a caplet successfully', async () => { - const controller = makeCapletController(config, deps); + const mockStorage = createMockStorage(emptyState); + const controller = makeCapletController(config, { + storage: mockStorage, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + const result = await controller.install(validManifest); expect(result).toStrictEqual({ @@ -65,7 +98,13 @@ describe('makeCapletController', () => { }); it('validates the manifest', async () => { - const controller = makeCapletController(config, deps); + const mockStorage = createMockStorage(emptyState); + const controller = makeCapletController(config, { + storage: mockStorage, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + const invalidManifest = { id: 'invalid' } as CapletManifest; await expect(controller.install(invalidManifest)).rejects.toThrow( @@ -74,22 +113,35 @@ describe('makeCapletController', () => { }); it('throws if caplet already installed', async () => { - vi.mocked(mockStorage.get).mockImplementation(async (key: string) => { - if (key === 'com.example.test.manifest') { - return validManifest; - } - return undefined; + const stateWithCaplet: CapletControllerState = { + caplets: { + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-123', + installedAt: 1000, + }, + }, + }; + const mockStorage = createMockStorage(stateWithCaplet); + const controller = makeCapletController(config, { + storage: mockStorage, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, }); - const controller = makeCapletController(config, deps); - await expect(controller.install(validManifest)).rejects.toThrow( 'Caplet com.example.test is already installed', ); }); it('launches subcluster with correct config', async () => { - const controller = makeCapletController(config, deps); + const mockStorage = createMockStorage(emptyState); + const controller = makeCapletController(config, { + storage: mockStorage, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + await controller.install(validManifest); expect(mockLaunchSubcluster).toHaveBeenCalledWith({ @@ -102,67 +154,61 @@ describe('makeCapletController', () => { }); }); - it('stores manifest, subclusterId, and installedAt', async () => { + it('stores caplet with manifest, subclusterId, and installedAt', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2024-01-15T12:00:00Z')); - const controller = makeCapletController(config, deps); + const mockStorage = createMockStorage(emptyState); + const controller = makeCapletController(config, { + storage: mockStorage, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + await controller.install(validManifest); - expect(mockStorage.set).toHaveBeenCalledWith( - 'com.example.test.manifest', - validManifest, - ); - expect(mockStorage.set).toHaveBeenCalledWith( - 'com.example.test.subclusterId', - 'subcluster-123', - ); - expect(mockStorage.set).toHaveBeenCalledWith( - 'com.example.test.installedAt', - Date.now(), - ); + const caplet = mockStorage.state.caplets['com.example.test']; + expect(caplet).toBeDefined(); + expect(caplet?.manifest).toStrictEqual(validManifest); + expect(caplet?.subclusterId).toBe('subcluster-123'); + expect(caplet?.installedAt).toBe(Date.now()); vi.useRealTimers(); }); - it('updates installed list', async () => { - vi.mocked(mockStorage.get).mockImplementation(async (key: string) => { - if (key === 'installed') { - return ['com.other.caplet']; - } - return undefined; + it('preserves existing caplets when installing', async () => { + const stateWithOtherCaplet: CapletControllerState = { + caplets: { + 'com.other.caplet': { + manifest: { ...validManifest, id: 'com.other.caplet' }, + subclusterId: 'subcluster-other', + installedAt: 500, + }, + }, + }; + const mockStorage = createMockStorage(stateWithOtherCaplet); + const controller = makeCapletController(config, { + storage: mockStorage, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, }); - const controller = makeCapletController(config, deps); await controller.install(validManifest); - expect(mockStorage.set).toHaveBeenCalledWith('installed', [ + expect(Object.keys(mockStorage.state.caplets)).toStrictEqual([ 'com.other.caplet', 'com.example.test', ]); }); - it('does not duplicate caplet id in installed list', async () => { - vi.mocked(mockStorage.get).mockImplementation(async (key: string) => { - if (key === 'installed') { - return ['com.example.test']; - } - // Return undefined for manifest to allow install to proceed - return undefined; + it('logs installation progress', async () => { + const mockStorage = createMockStorage(emptyState); + const controller = makeCapletController(config, { + storage: mockStorage, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, }); - const controller = makeCapletController(config, deps); - await controller.install(validManifest); - - // Should not add duplicate - expect(mockStorage.set).not.toHaveBeenCalledWith('installed', [ - 'com.example.test', - 'com.example.test', - ]); - }); - - it('logs installation progress', async () => { - const controller = makeCapletController(config, deps); await controller.install(validManifest); expect(mockLogger.info).toHaveBeenCalledWith( @@ -176,86 +222,108 @@ describe('makeCapletController', () => { describe('uninstall', () => { it('uninstalls a caplet successfully', async () => { - vi.mocked(mockStorage.get).mockImplementation(async (key: string) => { - if (key === 'com.example.test.subclusterId') { - return 'subcluster-123'; - } - if (key === 'installed') { - return ['com.example.test']; - } - return undefined; + const stateWithCaplet: CapletControllerState = { + caplets: { + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-123', + installedAt: 1000, + }, + }, + }; + const mockStorage = createMockStorage(stateWithCaplet); + const controller = makeCapletController(config, { + storage: mockStorage, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, }); - const controller = makeCapletController(config, deps); await controller.uninstall('com.example.test'); expect(mockTerminateSubcluster).toHaveBeenCalledWith('subcluster-123'); }); it('throws if caplet not found', async () => { - const controller = makeCapletController(config, deps); + const mockStorage = createMockStorage(emptyState); + const controller = makeCapletController(config, { + storage: mockStorage, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); await expect( controller.uninstall('com.example.notfound'), ).rejects.toThrow('Caplet com.example.notfound not found'); }); - it('removes all caplet data from storage', async () => { - vi.mocked(mockStorage.get).mockImplementation(async (key: string) => { - if (key === 'com.example.test.subclusterId') { - return 'subcluster-123'; - } - if (key === 'installed') { - return ['com.example.test']; - } - return undefined; + it('removes caplet from state', async () => { + const stateWithCaplet: CapletControllerState = { + caplets: { + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-123', + installedAt: 1000, + }, + }, + }; + const mockStorage = createMockStorage(stateWithCaplet); + const controller = makeCapletController(config, { + storage: mockStorage, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, }); - const controller = makeCapletController(config, deps); await controller.uninstall('com.example.test'); - expect(mockStorage.delete).toHaveBeenCalledWith( - 'com.example.test.manifest', - ); - expect(mockStorage.delete).toHaveBeenCalledWith( - 'com.example.test.subclusterId', - ); - expect(mockStorage.delete).toHaveBeenCalledWith( - 'com.example.test.installedAt', - ); + expect(mockStorage.state.caplets['com.example.test']).toBeUndefined(); }); - it('updates installed list', async () => { - vi.mocked(mockStorage.get).mockImplementation(async (key: string) => { - if (key === 'com.example.test.subclusterId') { - return 'subcluster-123'; - } - if (key === 'installed') { - return ['com.other.caplet', 'com.example.test']; - } - return undefined; + it('preserves other caplets when uninstalling', async () => { + const stateWithCaplets: CapletControllerState = { + caplets: { + 'com.other.caplet': { + manifest: { ...validManifest, id: 'com.other.caplet' }, + subclusterId: 'subcluster-other', + installedAt: 500, + }, + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-123', + installedAt: 1000, + }, + }, + }; + const mockStorage = createMockStorage(stateWithCaplets); + const controller = makeCapletController(config, { + storage: mockStorage, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, }); - const controller = makeCapletController(config, deps); await controller.uninstall('com.example.test'); - expect(mockStorage.set).toHaveBeenCalledWith('installed', [ + expect(Object.keys(mockStorage.state.caplets)).toStrictEqual([ 'com.other.caplet', ]); }); it('logs uninstallation progress', async () => { - vi.mocked(mockStorage.get).mockImplementation(async (key: string) => { - if (key === 'com.example.test.subclusterId') { - return 'subcluster-123'; - } - if (key === 'installed') { - return ['com.example.test']; - } - return undefined; + const stateWithCaplet: CapletControllerState = { + caplets: { + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-123', + installedAt: 1000, + }, + }, + }; + const mockStorage = createMockStorage(stateWithCaplet); + const controller = makeCapletController(config, { + storage: mockStorage, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, }); - const controller = makeCapletController(config, deps); await controller.uninstall('com.example.test'); expect(mockLogger.info).toHaveBeenCalledWith( @@ -269,7 +337,13 @@ describe('makeCapletController', () => { describe('list', () => { it('returns empty array when no caplets installed', async () => { - const controller = makeCapletController(config, deps); + const mockStorage = createMockStorage(emptyState); + const controller = makeCapletController(config, { + storage: mockStorage, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + const result = await controller.list(); expect(result).toStrictEqual([]); @@ -281,90 +355,61 @@ describe('makeCapletController', () => { id: 'com.example.test2', name: 'Test Caplet 2', }; - - vi.mocked(mockStorage.get).mockImplementation(async (key: string) => { - if (key === 'installed') { - return ['com.example.test', 'com.example.test2']; - } - if (key === 'com.example.test.manifest') { - return validManifest; - } - if (key === 'com.example.test.subclusterId') { - return 'subcluster-1'; - } - if (key === 'com.example.test.installedAt') { - return 1000; - } - if (key === 'com.example.test2.manifest') { - return manifest2; - } - if (key === 'com.example.test2.subclusterId') { - return 'subcluster-2'; - } - if (key === 'com.example.test2.installedAt') { - return 2000; - } - return undefined; + const stateWithCaplets: CapletControllerState = { + caplets: { + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-1', + installedAt: 1000, + }, + 'com.example.test2': { + manifest: manifest2, + subclusterId: 'subcluster-2', + installedAt: 2000, + }, + }, + }; + const mockStorage = createMockStorage(stateWithCaplets); + const controller = makeCapletController(config, { + storage: mockStorage, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, }); - const controller = makeCapletController(config, deps); const result = await controller.list(); expect(result).toHaveLength(2); - expect(result[0]).toStrictEqual({ + expect(result).toContainEqual({ manifest: validManifest, subclusterId: 'subcluster-1', installedAt: 1000, }); - expect(result[1]).toStrictEqual({ + expect(result).toContainEqual({ manifest: manifest2, subclusterId: 'subcluster-2', installedAt: 2000, }); }); - - it('skips caplets with missing data', async () => { - vi.mocked(mockStorage.get).mockImplementation(async (key: string) => { - if (key === 'installed') { - return ['com.example.test', 'com.example.missing']; - } - if (key === 'com.example.test.manifest') { - return validManifest; - } - if (key === 'com.example.test.subclusterId') { - return 'subcluster-1'; - } - if (key === 'com.example.test.installedAt') { - return 1000; - } - // com.example.missing has no data - return undefined; - }); - - const controller = makeCapletController(config, deps); - const result = await controller.list(); - - expect(result).toHaveLength(1); - expect(result[0]?.manifest.id).toBe('com.example.test'); - }); }); describe('get', () => { it('returns caplet if exists', async () => { - vi.mocked(mockStorage.get).mockImplementation(async (key: string) => { - if (key === 'com.example.test.manifest') { - return validManifest; - } - if (key === 'com.example.test.subclusterId') { - return 'subcluster-123'; - } - if (key === 'com.example.test.installedAt') { - return 1705320000000; - } - return undefined; + const stateWithCaplet: CapletControllerState = { + caplets: { + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-123', + installedAt: 1705320000000, + }, + }, + }; + const mockStorage = createMockStorage(stateWithCaplet); + const controller = makeCapletController(config, { + storage: mockStorage, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, }); - const controller = makeCapletController(config, deps); const result = await controller.get('com.example.test'); expect(result).toStrictEqual({ @@ -375,50 +420,37 @@ describe('makeCapletController', () => { }); it('returns undefined if caplet not found', async () => { - const controller = makeCapletController(config, deps); - const result = await controller.get('com.example.notfound'); - - expect(result).toBeUndefined(); - }); - - it('returns undefined and logs warning if storage data corrupted', async () => { - vi.mocked(mockStorage.get).mockImplementation(async (key: string) => { - if (key === 'com.example.test.manifest') { - return validManifest; - } - // Missing subclusterId and installedAt - return undefined; + const mockStorage = createMockStorage(emptyState); + const controller = makeCapletController(config, { + storage: mockStorage, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, }); - const controller = makeCapletController(config, deps); - const result = await controller.get('com.example.test'); + const result = await controller.get('com.example.notfound'); expect(result).toBeUndefined(); - expect(mockLogger.warn).toHaveBeenCalledWith( - 'Caplet com.example.test has corrupted storage data', - ); }); }); describe('getByService', () => { it('returns caplet providing the service', async () => { - vi.mocked(mockStorage.get).mockImplementation(async (key: string) => { - if (key === 'installed') { - return ['com.example.test']; - } - if (key === 'com.example.test.manifest') { - return validManifest; // providedServices: ['signer'] - } - if (key === 'com.example.test.subclusterId') { - return 'subcluster-123'; - } - if (key === 'com.example.test.installedAt') { - return 1000; - } - return undefined; + const stateWithCaplet: CapletControllerState = { + caplets: { + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-123', + installedAt: 1000, + }, + }, + }; + const mockStorage = createMockStorage(stateWithCaplet); + const controller = makeCapletController(config, { + storage: mockStorage, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, }); - const controller = makeCapletController(config, deps); const result = await controller.getByService('signer'); expect(result).toBeDefined(); @@ -426,66 +458,59 @@ describe('makeCapletController', () => { }); it('returns undefined if no caplet provides the service', async () => { - vi.mocked(mockStorage.get).mockImplementation(async (key: string) => { - if (key === 'installed') { - return ['com.example.test']; - } - if (key === 'com.example.test.manifest') { - return validManifest; - } - if (key === 'com.example.test.subclusterId') { - return 'subcluster-123'; - } - if (key === 'com.example.test.installedAt') { - return 1000; - } - return undefined; + const stateWithCaplet: CapletControllerState = { + caplets: { + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-123', + installedAt: 1000, + }, + }, + }; + const mockStorage = createMockStorage(stateWithCaplet); + const controller = makeCapletController(config, { + storage: mockStorage, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, }); - const controller = makeCapletController(config, deps); const result = await controller.getByService('unknown-service'); expect(result).toBeUndefined(); }); - it('returns first matching caplet when multiple provide the service', async () => { + it('returns a matching caplet when multiple provide the service', async () => { const manifest2: CapletManifest = { ...validManifest, id: 'com.example.test2', name: 'Test Caplet 2', providedServices: ['signer', 'verifier'], }; - - vi.mocked(mockStorage.get).mockImplementation(async (key: string) => { - if (key === 'installed') { - return ['com.example.test', 'com.example.test2']; - } - if (key === 'com.example.test.manifest') { - return validManifest; - } - if (key === 'com.example.test.subclusterId') { - return 'subcluster-1'; - } - if (key === 'com.example.test.installedAt') { - return 1000; - } - if (key === 'com.example.test2.manifest') { - return manifest2; - } - if (key === 'com.example.test2.subclusterId') { - return 'subcluster-2'; - } - if (key === 'com.example.test2.installedAt') { - return 2000; - } - return undefined; + const stateWithCaplets: CapletControllerState = { + caplets: { + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-1', + installedAt: 1000, + }, + 'com.example.test2': { + manifest: manifest2, + subclusterId: 'subcluster-2', + installedAt: 2000, + }, + }, + }; + const mockStorage = createMockStorage(stateWithCaplets); + const controller = makeCapletController(config, { + storage: mockStorage, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, }); - const controller = makeCapletController(config, deps); const result = await controller.getByService('signer'); - // Returns first match - expect(result?.manifest.id).toBe('com.example.test'); + // Returns a match (object key order is not guaranteed) + expect(result?.manifest.providedServices).toContain('signer'); }); }); }); diff --git a/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts index 3a4ed0ed0..8c7b8a700 100644 --- a/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts +++ b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts @@ -9,49 +9,18 @@ import type { LaunchResult, } from './types.ts'; import { isCapletManifest } from './types.ts'; -import type { NamespacedStorage } from '../storage/types.ts'; +import type { ControllerStorage } from '../storage/controller-storage.ts'; import type { ControllerConfig } from '../types.ts'; /** - * Storage keys used by the CapletController within its namespace. + * Caplet controller persistent state. + * This is the shape of the state managed by the CapletController + * through the ControllerStorage abstraction. */ -const STORAGE_KEYS = { - /** List of installed caplet IDs */ - INSTALLED_LIST: 'installed', - /** Suffix for manifest storage: `${capletId}.manifest` */ - MANIFEST_SUFFIX: '.manifest', - /** Suffix for subclusterId storage: `${capletId}.subclusterId` */ - SUBCLUSTER_SUFFIX: '.subclusterId', - /** Suffix for installedAt storage: `${capletId}.installedAt` */ - INSTALLED_AT_SUFFIX: '.installedAt', -} as const; - -/** - * Generate storage key for a caplet's manifest. - * - * @param capletId - The caplet ID. - * @returns The storage key. - */ -const manifestKey = (capletId: CapletId): string => - `${capletId}${STORAGE_KEYS.MANIFEST_SUFFIX}`; - -/** - * Generate storage key for a caplet's subclusterId. - * - * @param capletId - The caplet ID. - * @returns The storage key. - */ -const subclusterKey = (capletId: CapletId): string => - `${capletId}${STORAGE_KEYS.SUBCLUSTER_SUFFIX}`; - -/** - * Generate storage key for a caplet's installedAt timestamp. - * - * @param capletId - The caplet ID. - * @returns The storage key. - */ -const installedAtKey = (capletId: CapletId): string => - `${capletId}${STORAGE_KEYS.INSTALLED_AT_SUFFIX}`; +export type CapletControllerState = { + /** Installed caplets keyed by caplet ID */ + caplets: Record; +}; /** * Methods exposed by the CapletController. @@ -105,8 +74,8 @@ export type CapletControllerMethods = { * These are attenuated - only the methods needed are provided. */ export type CapletControllerDeps = { - /** Namespaced storage for caplet data */ - storage: NamespacedStorage; + /** State storage for caplet data */ + storage: ControllerStorage; /** Launch a subcluster for a caplet */ launchSubcluster: (config: ClusterConfig) => Promise; /** Terminate a caplet's subcluster */ @@ -133,73 +102,22 @@ export function makeCapletController( const { storage, launchSubcluster, terminateSubcluster } = deps; /** - * Get the list of installed caplet IDs. - * - * @returns Array of installed caplet IDs. - */ - const getInstalledIds = async (): Promise => { - const ids = await storage.get(STORAGE_KEYS.INSTALLED_LIST); - return ids ?? []; - }; - - /** - * Update the list of installed caplet IDs. - * - * @param ids - The list of caplet IDs to store. - */ - const setInstalledIds = async (ids: CapletId[]): Promise => { - await storage.set(STORAGE_KEYS.INSTALLED_LIST, ids); - }; - - /** - * Internal get implementation (to avoid `this` binding issues in exo). + * Get an installed caplet by ID (synchronous - reads from in-memory state). * * @param capletId - The caplet ID to retrieve. * @returns The installed caplet or undefined if not found. */ - const getCaplet = async ( - capletId: CapletId, - ): Promise => { - const manifest = await storage.get(manifestKey(capletId)); - if (manifest === undefined) { - return undefined; - } - - const [subclusterId, installedAt] = await Promise.all([ - storage.get(subclusterKey(capletId)), - storage.get(installedAtKey(capletId)), - ]); - - if (subclusterId === undefined || installedAt === undefined) { - // Corrupted data - manifest exists but other fields don't - logger.warn(`Caplet ${capletId} has corrupted storage data`); - return undefined; - } - - return { - manifest, - subclusterId, - installedAt, - }; + const getCaplet = (capletId: CapletId): InstalledCaplet | undefined => { + return storage.state.caplets[capletId]; }; /** - * Internal list implementation (to avoid `this` binding issues in exo). + * Get all installed caplets (synchronous - reads from in-memory state). * * @returns Array of all installed caplets. */ - const listCaplets = async (): Promise => { - const installedIds = await getInstalledIds(); - const caplets: InstalledCaplet[] = []; - - for (const id of installedIds) { - const caplet = await getCaplet(id); - if (caplet !== undefined) { - caplets.push(caplet); - } - } - - return caplets; + const listCaplets = (): InstalledCaplet[] => { + return Object.values(storage.state.caplets); }; return makeDefaultExo('CapletController', { @@ -216,8 +134,7 @@ export function makeCapletController( } // Check if already installed - const existing = await storage.get(manifestKey(id)); - if (existing !== undefined) { + if (storage.state.caplets[id] !== undefined) { throw new Error(`Caplet ${id} is already installed`); } @@ -235,18 +152,13 @@ export function makeCapletController( const { subclusterId } = await launchSubcluster(clusterConfig); // Store caplet data - const now = Date.now(); - await Promise.all([ - storage.set(manifestKey(id), manifest), - storage.set(subclusterKey(id), subclusterId), - storage.set(installedAtKey(id), now), - ]); - - // Update installed list - const installedIds = await getInstalledIds(); - if (!installedIds.includes(id)) { - await setInstalledIds([...installedIds, id]); - } + await storage.update((draft) => { + draft.caplets[id] = { + manifest, + subclusterId, + installedAt: Date.now(), + }; + }); logger.info(`Caplet ${id} installed with subcluster ${subclusterId}`); return { capletId: id, subclusterId }; @@ -255,24 +167,18 @@ export function makeCapletController( async uninstall(capletId: CapletId): Promise { logger.info(`Uninstalling caplet: ${capletId}`); - const subclusterId = await storage.get(subclusterKey(capletId)); - if (subclusterId === undefined) { + const caplet = storage.state.caplets[capletId]; + if (caplet === undefined) { throw new Error(`Caplet ${capletId} not found`); } // Terminate the subcluster - await terminateSubcluster(subclusterId); + await terminateSubcluster(caplet.subclusterId); // Remove from storage - await Promise.all([ - storage.delete(manifestKey(capletId)), - storage.delete(subclusterKey(capletId)), - storage.delete(installedAtKey(capletId)), - ]); - - // Update installed list - const installedIds = await getInstalledIds(); - await setInstalledIds(installedIds.filter((id) => id !== capletId)); + await storage.update((draft) => { + delete draft.caplets[capletId]; + }); logger.info(`Caplet ${capletId} uninstalled`); }, @@ -288,7 +194,7 @@ export function makeCapletController( async getByService( serviceName: string, ): Promise { - const caplets = await listCaplets(); + const caplets = listCaplets(); return caplets.find((caplet: InstalledCaplet) => caplet.manifest.providedServices.includes(serviceName), ); diff --git a/packages/omnium-gatherum/src/controllers/caplet/index.ts b/packages/omnium-gatherum/src/controllers/caplet/index.ts index e2b30889c..e0cb3f5cf 100644 --- a/packages/omnium-gatherum/src/controllers/caplet/index.ts +++ b/packages/omnium-gatherum/src/controllers/caplet/index.ts @@ -18,5 +18,6 @@ export { export type { CapletControllerMethods, CapletControllerDeps, + CapletControllerState, } from './caplet-controller.ts'; export { makeCapletController } from './caplet-controller.ts'; diff --git a/packages/omnium-gatherum/src/controllers/facet.ts b/packages/omnium-gatherum/src/controllers/facet.ts index 95e8a1338..2ec6dc269 100644 --- a/packages/omnium-gatherum/src/controllers/facet.ts +++ b/packages/omnium-gatherum/src/controllers/facet.ts @@ -1,13 +1,14 @@ -import type { Methods, MethodGuard } from '@endo/exo'; +import type { Methods } from '@endo/exo'; import { makeDefaultExo } from '@metamask/kernel-utils/exo'; -// RemotableMethodName from @endo/pass-style is string | symbol -type MethodKeys = Extract< - { - [Key in keyof Source]: Source[Key] extends CallableFunction ? Key : never; - }[keyof Source], - keyof MethodGuard ->; +/** + * Extract keys from Source that are callable functions. + * Filters to string | symbol to match RemotableMethodName from @endo/pass-style. + */ +type MethodKeys = { + [Key in keyof Source]: Source[Key] extends CallableFunction ? Key : never; +}[keyof Source] & + (string | symbol); type BoundMethod = Func extends CallableFunction ? OmitThisParameter diff --git a/packages/omnium-gatherum/src/controllers/index.ts b/packages/omnium-gatherum/src/controllers/index.ts index 25b792d4b..22abb8f00 100644 --- a/packages/omnium-gatherum/src/controllers/index.ts +++ b/packages/omnium-gatherum/src/controllers/index.ts @@ -3,10 +3,16 @@ export type { ControllerConfig, FacetOf } from './types.ts'; export { makeFacet } from './facet.ts'; // Storage -export type { NamespacedStorage, StorageAdapter } from './storage/index.ts'; +export type { + NamespacedStorage, + StorageAdapter, + ControllerStorage, + ControllerStorageConfig, +} from './storage/index.ts'; export { makeChromeStorageAdapter, makeNamespacedStorage, + makeControllerStorage, } from './storage/index.ts'; // Caplet @@ -17,6 +23,7 @@ export type { InstalledCaplet, InstallResult, LaunchResult, + CapletControllerState, CapletControllerMethods, CapletControllerDeps, } from './caplet/index.ts'; diff --git a/packages/omnium-gatherum/src/controllers/storage/controller-storage.test.ts b/packages/omnium-gatherum/src/controllers/storage/controller-storage.test.ts new file mode 100644 index 000000000..c01093441 --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/storage/controller-storage.test.ts @@ -0,0 +1,336 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { makeControllerStorage } from './controller-storage.ts'; +import type { StorageAdapter } from './types.ts'; + +type TestState = { + installed: string[]; + manifests: Record; + count: number; +}; + +describe('makeControllerStorage', () => { + const mockAdapter: StorageAdapter = { + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), + keys: vi.fn(), + }; + + const defaultState: TestState = { + installed: [], + manifests: {}, + count: 0, + }; + + beforeEach(() => { + vi.mocked(mockAdapter.get).mockResolvedValue(undefined); + vi.mocked(mockAdapter.set).mockResolvedValue(undefined); + vi.mocked(mockAdapter.delete).mockResolvedValue(undefined); + vi.mocked(mockAdapter.keys).mockResolvedValue([]); + }); + + describe('initialization', () => { + it('loads existing state from storage on creation', async () => { + vi.mocked(mockAdapter.keys).mockResolvedValue([ + 'test.installed', + 'test.manifests', + ]); + vi.mocked(mockAdapter.get).mockImplementation(async (key: string) => { + if (key === 'test.installed') { + return ['app1']; + } + if (key === 'test.manifests') { + return { app1: { name: 'App 1' } }; + } + return undefined; + }); + + const storage = await makeControllerStorage({ + namespace: 'test', + adapter: mockAdapter, + defaultState, + }); + + expect(storage.state.installed).toStrictEqual(['app1']); + expect(storage.state.manifests).toStrictEqual({ + app1: { name: 'App 1' }, + }); + }); + + it('uses defaults for missing keys', async () => { + vi.mocked(mockAdapter.keys).mockResolvedValue(['test.installed']); + vi.mocked(mockAdapter.get).mockImplementation(async (key: string) => { + if (key === 'test.installed') { + return ['existing']; + } + return undefined; + }); + + const storage = await makeControllerStorage({ + namespace: 'test', + adapter: mockAdapter, + defaultState: { + installed: [] as string[], + manifests: {}, + metadata: { version: 1 }, + }, + }); + + expect(storage.state.installed).toStrictEqual(['existing']); + expect(storage.state.manifests).toStrictEqual({}); + expect(storage.state.metadata).toStrictEqual({ version: 1 }); + }); + + it('uses all defaults when storage is empty', async () => { + vi.mocked(mockAdapter.keys).mockResolvedValue([]); + + const storage = await makeControllerStorage({ + namespace: 'test', + adapter: mockAdapter, + defaultState, + }); + + expect(storage.state.installed).toStrictEqual([]); + expect(storage.state.manifests).toStrictEqual({}); + expect(storage.state.count).toBe(0); + }); + + it('returns hardened state copy', async () => { + const storage = await makeControllerStorage({ + namespace: 'test', + adapter: mockAdapter, + defaultState: { items: ['original'] as string[] }, + }); + + // Get a reference to the state + const state1 = storage.state; + + // Modifications to the returned state should not affect the internal state + // (In SES environment, this would throw; in tests, we verify isolation) + try { + (state1 as { items: string[] }).items.push('modified'); + } catch { + // Expected in SES environment + } + + // Get a fresh state - it should still have the original value + const state2 = storage.state; + expect(state2.items).toStrictEqual(['original']); + }); + }); + + describe('state access', () => { + it('provides readonly access to current state', async () => { + vi.mocked(mockAdapter.keys).mockResolvedValue(['ns.count']); + vi.mocked(mockAdapter.get).mockResolvedValue(42); + + const storage = await makeControllerStorage({ + namespace: 'ns', + adapter: mockAdapter, + defaultState: { count: 0 }, + }); + + expect(storage.state.count).toBe(42); + }); + }); + + describe('update', () => { + it('persists only modified top-level keys', async () => { + const storage = await makeControllerStorage({ + namespace: 'test', + adapter: mockAdapter, + defaultState, + }); + + await storage.update((draft) => { + draft.installed.push('new-app'); + // manifests and count not modified + }); + + expect(mockAdapter.set).toHaveBeenCalledTimes(1); + expect(mockAdapter.set).toHaveBeenCalledWith('test.installed', [ + 'new-app', + ]); + }); + + it('updates in-memory state after persistence', async () => { + const storage = await makeControllerStorage({ + namespace: 'test', + adapter: mockAdapter, + defaultState, + }); + + await storage.update((draft) => { + draft.installed.push('item1'); + }); + + expect(storage.state.installed).toStrictEqual(['item1']); + }); + + it('does not persist when no changes made', async () => { + const storage = await makeControllerStorage({ + namespace: 'test', + adapter: mockAdapter, + defaultState, + }); + + await storage.update((draft) => { + // No actual changes + draft.count = 0; + }); + + expect(mockAdapter.set).not.toHaveBeenCalled(); + }); + + it('persists multiple modified keys', async () => { + const storage = await makeControllerStorage({ + namespace: 'test', + adapter: mockAdapter, + defaultState: { a: 1, b: 2, c: 3 }, + }); + + await storage.update((draft) => { + draft.a = 10; + draft.c = 30; + }); + + expect(mockAdapter.set).toHaveBeenCalledTimes(2); + expect(mockAdapter.set).toHaveBeenCalledWith('test.a', 10); + expect(mockAdapter.set).toHaveBeenCalledWith('test.c', 30); + }); + + it('does not update state if persistence fails', async () => { + vi.mocked(mockAdapter.set).mockRejectedValue(new Error('Storage error')); + + const storage = await makeControllerStorage({ + namespace: 'test', + adapter: mockAdapter, + defaultState, + }); + + await expect( + storage.update((draft) => { + draft.count = 100; + }), + ).rejects.toThrow('Storage error'); + + // State should remain unchanged + expect(storage.state.count).toBe(0); + }); + + it('handles nested object modifications', async () => { + const storage = await makeControllerStorage({ + namespace: 'test', + adapter: mockAdapter, + defaultState, + }); + + await storage.update((draft) => { + draft.manifests['new-app'] = { name: 'New App' }; + }); + + expect(mockAdapter.set).toHaveBeenCalledWith('test.manifests', { + 'new-app': { name: 'New App' }, + }); + }); + + it('handles array operations', async () => { + vi.mocked(mockAdapter.keys).mockResolvedValue(['test.installed']); + vi.mocked(mockAdapter.get).mockResolvedValue(['app1', 'app2']); + + const storage = await makeControllerStorage({ + namespace: 'test', + adapter: mockAdapter, + defaultState, + }); + + await storage.update((draft) => { + draft.installed = draft.installed.filter((id) => id !== 'app1'); + }); + + expect(mockAdapter.set).toHaveBeenCalledWith('test.installed', ['app2']); + }); + + it('handles delete operations on nested objects', async () => { + vi.mocked(mockAdapter.keys).mockResolvedValue(['test.manifests']); + vi.mocked(mockAdapter.get).mockResolvedValue({ + app1: { name: 'App 1' }, + app2: { name: 'App 2' }, + }); + + const storage = await makeControllerStorage({ + namespace: 'test', + adapter: mockAdapter, + defaultState, + }); + + await storage.update((draft) => { + delete draft.manifests.app1; + }); + + expect(mockAdapter.set).toHaveBeenCalledWith('test.manifests', { + app2: { name: 'App 2' }, + }); + }); + }); + + describe('reload', () => { + it('reloads state from storage', async () => { + vi.mocked(mockAdapter.keys).mockResolvedValue([]); + + const storage = await makeControllerStorage({ + namespace: 'test', + adapter: mockAdapter, + defaultState, + }); + + expect(storage.state.count).toBe(0); + + // Simulate external storage update + vi.mocked(mockAdapter.keys).mockResolvedValue(['test.count']); + vi.mocked(mockAdapter.get).mockResolvedValue(999); + + await storage.reload(); + + expect(storage.state.count).toBe(999); + }); + + it('merges with defaults after reload', async () => { + vi.mocked(mockAdapter.keys).mockResolvedValue(['test.count']); + vi.mocked(mockAdapter.get).mockResolvedValue(42); + + const storage = await makeControllerStorage({ + namespace: 'test', + adapter: mockAdapter, + defaultState, + }); + + // Reload - count from storage, others from defaults + await storage.reload(); + + expect(storage.state.count).toBe(42); + expect(storage.state.installed).toStrictEqual([]); + expect(storage.state.manifests).toStrictEqual({}); + }); + }); + + describe('namespace isolation', () => { + it('uses different prefixes for different namespaces', async () => { + await makeControllerStorage({ + namespace: 'caplet', + adapter: mockAdapter, + defaultState: { value: 1 }, + }); + + await makeControllerStorage({ + namespace: 'service', + adapter: mockAdapter, + defaultState: { value: 2 }, + }); + + expect(mockAdapter.keys).toHaveBeenCalledWith('caplet.'); + expect(mockAdapter.keys).toHaveBeenCalledWith('service.'); + }); + }); +}); diff --git a/packages/omnium-gatherum/src/controllers/storage/controller-storage.ts b/packages/omnium-gatherum/src/controllers/storage/controller-storage.ts new file mode 100644 index 000000000..22ef617f7 --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/storage/controller-storage.ts @@ -0,0 +1,224 @@ +import type { Json } from '@metamask/utils'; +import { enablePatches, produce } from 'immer'; +import type { Patch } from 'immer'; + +import type { StorageAdapter } from './types.ts'; + +// Enable immer patches globally (called once at module load) +enablePatches(); + +// TODO: Add migration utility for converting from per-key storage format +// (e.g., caplet.{id}.manifest) to consolidated state format (caplet.manifests) +// when there is deployed data to migrate. + +/** + * Configuration for creating a ControllerStorage instance. + */ +export type ControllerStorageConfig> = { + /** The namespace prefix for storage keys (e.g., 'caplet') */ + namespace: string; + /** The underlying storage adapter */ + adapter: StorageAdapter; + /** Default state values - used for initialization and type inference */ + defaultState: State; +}; + +/** + * ControllerStorage provides a simplified state management interface for controllers. + * + * Features: + * - Flat top-level key mapping: `state.foo` maps to `{namespace}.foo` in storage + * - Immer-based updates with automatic change detection + * - Only modified top-level keys are persisted + * - Eager loading on initialization + * + * @template State - The state object type (must have Json-serializable values) + */ +export type ControllerStorage> = { + /** + * Current state (readonly, hardened). + * Access individual properties: `storage.state.installed` + */ + readonly state: Readonly; + + /** + * Update state using an immer producer function. + * Only modified top-level keys will be persisted to storage. + * + * @param producer - Function that mutates a draft of the state + * @returns Promise that resolves when changes are persisted + * @throws If storage persistence fails (state remains unchanged) + * + * @example + * ```typescript + * await storage.update(draft => { + * draft.installed.push('com.example.app'); + * draft.manifests['com.example.app'] = manifest; + * }); + * ``` + */ + update: (producer: (draft: State) => void) => Promise; + + /** + * Force reload state from storage. + * Useful for syncing after external storage changes. + */ + reload: () => Promise; +}; + +/** + * Create a ControllerStorage instance for a controller. + * + * This factory function: + * 1. Loads existing state from storage for the namespace + * 2. Merges with defaults (storage values take precedence) + * 3. Returns a hardened ControllerStorage interface + * + * @param config - Configuration including namespace, adapter, and default state. + * @returns Promise resolving to a hardened ControllerStorage instance. + * + * @example + * ```typescript + * const capletState = await makeControllerStorage({ + * namespace: 'caplet', + * adapter: storageAdapter, + * defaultState: { installed: [], manifests: {} } + * }); + * + * // Read state + * console.log(capletState.state.installed); + * + * // Update state + * await capletState.update(draft => { + * draft.installed.push('com.example.app'); + * }); + * ``` + */ +export async function makeControllerStorage>( + config: ControllerStorageConfig, +): Promise> { + const { namespace, adapter, defaultState } = config; + const prefix = `${namespace}.`; + + /** + * Build a storage key from a state property name. + * + * @param stateKey - The state property name. + * @returns The namespaced storage key. + */ + const buildKey = (stateKey: string): string => `${prefix}${stateKey}`; + + /** + * Strip namespace prefix from a storage key. + * + * @param fullKey - The full namespaced storage key. + * @returns The state property name without prefix. + */ + const stripPrefix = (fullKey: string): string => fullKey.slice(prefix.length); + + /** + * Load all state from storage, merging with defaults. + * Storage values take precedence over defaults. + * + * @returns The merged state object. + */ + const loadState = async (): Promise => { + const allKeys = await adapter.keys(prefix); + + // Start with a copy of defaults + const state = { ...defaultState }; + + // Load and merge values from storage + await Promise.all( + allKeys.map(async (fullKey) => { + const key = stripPrefix(fullKey) as keyof State; + const value = await adapter.get(fullKey); + if (value !== undefined) { + state[key] = value as State[keyof State]; + } + }), + ); + + return produce({}, (draft) => { + Object.assign(draft, state); + }) as State; + }; + + /** + * Persist specific keys to storage. + * + * @param stateToSave - The state object containing values to persist. + * @param keys - Set of top-level keys to persist. + */ + const persistKeys = async ( + stateToSave: State, + keys: Set, + ): Promise => { + await Promise.all( + Array.from(keys).map(async (key) => { + const storageKey = buildKey(key); + const value = stateToSave[key as keyof State]; + await adapter.set(storageKey, value as Json); + }), + ); + }; + + /** + * Extract top-level keys that were modified from immer patches. + * + * @param patches - Array of immer patches describing changes. + * @returns Set of modified top-level keys. + */ + const getModifiedKeys = (patches: Patch[]): Set => { + const keys = new Set(); + for (const patch of patches) { + // The first element of path is always the top-level key + if (patch.path.length > 0) { + keys.add(String(patch.path[0])); + } + } + return keys; + }; + + // Load initial state + let currentState = await loadState(); + + const storage: ControllerStorage = { + get state(): Readonly { + return currentState; + }, + + async update(producer: (draft: State) => void): Promise { + // Capture state before async operations to avoid race conditions + const stateSnapshot = currentState; + + // Use immer's produce with patches callback to track changes + let patches: Patch[] = []; + const nextState = produce(stateSnapshot, producer, (patchList) => { + patches = patchList; + }); + + // No changes - nothing to do + if (patches.length === 0) { + return; + } + + // Determine which top-level keys changed + const modifiedKeys = getModifiedKeys(patches); + + // Persist only the modified keys + await persistKeys(nextState, modifiedKeys); + + // Update in-memory state only after successful persistence + // eslint-disable-next-line require-atomic-updates -- Last-write-wins is intentional + currentState = nextState; + }, + + async reload(): Promise { + currentState = await loadState(); + }, + }; + + return harden(storage); +} +harden(makeControllerStorage); diff --git a/packages/omnium-gatherum/src/controllers/storage/index.ts b/packages/omnium-gatherum/src/controllers/storage/index.ts index 5d9628d33..9a88b8acf 100644 --- a/packages/omnium-gatherum/src/controllers/storage/index.ts +++ b/packages/omnium-gatherum/src/controllers/storage/index.ts @@ -1,3 +1,8 @@ export type { NamespacedStorage, StorageAdapter } from './types.ts'; +export type { + ControllerStorage, + ControllerStorageConfig, +} from './controller-storage.ts'; export { makeChromeStorageAdapter } from './chrome-storage.ts'; export { makeNamespacedStorage } from './namespaced-storage.ts'; +export { makeControllerStorage } from './controller-storage.ts'; diff --git a/yarn.lock b/yarn.lock index 0e64e8f48..196090ffc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3898,6 +3898,7 @@ __metadata: eslint-plugin-n: "npm:^17.17.0" eslint-plugin-prettier: "npm:^5.2.6" eslint-plugin-promise: "npm:^7.2.1" + immer: "npm:^10.1.1" jsdom: "npm:^27.4.0" playwright: "npm:^1.54.2" prettier: "npm:^3.5.3" @@ -9756,6 +9757,13 @@ __metadata: languageName: node linkType: hard +"immer@npm:^10.1.1": + version: 10.2.0 + resolution: "immer@npm:10.2.0" + checksum: 10/d73e218c8f8ffbb39f9290dfafa478b94af73403dcf26b5672eef35233bb30f09ffe231f8a78a6c9cb442968510edd89e851776ec90a5ddfa82cee6db6b35137 + languageName: node + linkType: hard + "immer@npm:^9.0.6": version: 9.0.21 resolution: "immer@npm:9.0.21" From 9b8c4c9d0bd397fd81ec6d9322b1082df327d5c2 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Fri, 9 Jan 2026 12:22:52 -0800 Subject: [PATCH 10/11] refactor(omnium): Add abstract Controller base class - Add abstract Controller class with state management via ControllerStorage - Convert CapletController to extend Controller base class - Use makeFacet() pattern for returning hardened exo methods - Add base-controller tests (12 tests) - Add semver deep import type declaration - Add storage permission to manifest.json Co-Authored-By: Claude Sonnet 4.5 --- packages/omnium-gatherum/package.json | 1 + packages/omnium-gatherum/src/background.ts | 4 +- .../src/controllers/base-controller.test.ts | 289 ++++++++++++++++++ .../src/controllers/base-controller.ts | 131 ++++++++ .../caplet/caplet-controller.test.ts | 42 +-- .../controllers/caplet/caplet-controller.ts | 285 ++++++++++------- .../src/controllers/caplet/index.ts | 4 +- .../omnium-gatherum/src/controllers/facet.ts | 2 +- .../omnium-gatherum/src/controllers/index.ts | 9 +- .../omnium-gatherum/src/controllers/types.ts | 9 +- packages/omnium-gatherum/src/manifest.json | 2 +- .../omnium-gatherum/src/types/semver.d.ts | 7 + yarn.lock | 9 +- 13 files changed, 648 insertions(+), 146 deletions(-) create mode 100644 packages/omnium-gatherum/src/controllers/base-controller.test.ts create mode 100644 packages/omnium-gatherum/src/controllers/base-controller.ts create mode 100644 packages/omnium-gatherum/src/types/semver.d.ts diff --git a/packages/omnium-gatherum/package.json b/packages/omnium-gatherum/package.json index 205814f7d..5bea4caba 100644 --- a/packages/omnium-gatherum/package.json +++ b/packages/omnium-gatherum/package.json @@ -74,6 +74,7 @@ "@types/chrome": "^0.0.313", "@types/react": "^17.0.11", "@types/react-dom": "^17.0.11", + "@types/semver": "^7.7.1", "@types/webextension-polyfill": "^0", "@typescript-eslint/eslint-plugin": "^8.29.0", "@typescript-eslint/parser": "^8.29.0", diff --git a/packages/omnium-gatherum/src/background.ts b/packages/omnium-gatherum/src/background.ts index f1d60fbcb..784c9fa7f 100644 --- a/packages/omnium-gatherum/src/background.ts +++ b/packages/omnium-gatherum/src/background.ts @@ -16,9 +16,9 @@ import type { ClusterConfig } from '@metamask/ocap-kernel'; import { ChromeRuntimeDuplexStream } from '@metamask/streams/browser'; import { + CapletController, makeChromeStorageAdapter, makeControllerStorage, - makeCapletController, } from './controllers/index.ts'; import type { CapletControllerState, @@ -136,7 +136,7 @@ async function main(): Promise { }); // Create CapletController with attenuated kernel access - const capletController = makeCapletController( + const capletController = CapletController.make( { logger: logger.subLogger({ tags: ['caplet'] }) }, { storage: capletStorage, diff --git a/packages/omnium-gatherum/src/controllers/base-controller.test.ts b/packages/omnium-gatherum/src/controllers/base-controller.test.ts new file mode 100644 index 000000000..d39abb533 --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/base-controller.test.ts @@ -0,0 +1,289 @@ +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; +import type { Logger } from '@metamask/logger'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { Controller } from './base-controller.ts'; +import type { ControllerConfig } from './base-controller.ts'; +import type { ControllerStorage } from './storage/controller-storage.ts'; + +/** + * Test state for the concrete test controller. + */ +type TestState = { + items: Record; + count: number; +}; + +/** + * Test methods for the concrete test controller. + */ +type TestMethods = { + addItem: (id: string, name: string, value: number) => Promise; + removeItem: (id: string) => Promise; + getItem: (id: string) => Promise<{ name: string; value: number } | undefined>; + getCount: () => Promise; +}; + +/** + * Concrete controller for testing the abstract Controller base class. + */ +class TestController extends Controller< + 'TestController', + TestState, + TestMethods +> { + // eslint-disable-next-line no-restricted-syntax -- TypeScript doesn't support # for constructors + private constructor(storage: ControllerStorage, logger: Logger) { + super('TestController', storage, logger); + harden(this); + } + + static create( + config: ControllerConfig, + storage: ControllerStorage, + ): TestMethods { + const controller = new TestController(storage, config.logger); + return controller.makeFacet(); + } + + makeFacet(): TestMethods { + return makeDefaultExo('TestController', { + addItem: async ( + id: string, + name: string, + value: number, + ): Promise => { + this.logger.info(`Adding item: ${id}`); + await this.update((draft) => { + draft.items[id] = { name, value }; + draft.count += 1; + }); + }, + removeItem: async (id: string): Promise => { + this.logger.info(`Removing item: ${id}`); + await this.update((draft) => { + delete draft.items[id]; + draft.count -= 1; + }); + }, + getItem: async ( + id: string, + ): Promise<{ name: string; value: number } | undefined> => { + return this.state.items[id]; + }, + getCount: async (): Promise => { + return this.state.count; + }, + }); + } +} +harden(TestController); + +/** + * Create a mock ControllerStorage for testing. + * + * @param initialState - The initial state for the mock storage. + * @returns A mock ControllerStorage instance with update tracking. + */ +function createMockStorage( + initialState: TestState, +): ControllerStorage & { updateCalls: (() => void)[] } { + let currentState = { ...initialState }; + const updateCalls: (() => void)[] = []; + + return { + get state(): Readonly { + return harden({ ...currentState }); + }, + + async update(producer: (draft: TestState) => void): Promise { + // Create a mutable draft + const draft = JSON.parse(JSON.stringify(currentState)) as TestState; + producer(draft); + currentState = draft; + updateCalls.push(() => producer(draft)); + }, + + async reload(): Promise { + // No-op for tests + }, + + updateCalls, + }; +} + +const emptyState: TestState = { + items: {}, + count: 0, +}; + +describe('Controller', () => { + const mockLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + subLogger: vi.fn().mockReturnThis(), + }; + + const config: ControllerConfig = { + logger: mockLogger as unknown as ControllerConfig['logger'], + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('state access', () => { + it('provides read-only access to state', async () => { + const initialState: TestState = { + items: { foo: { name: 'Foo', value: 42 } }, + count: 1, + }; + const mockStorage = createMockStorage(initialState); + const controller = TestController.create(config, mockStorage); + + const item = await controller.getItem('foo'); + + expect(item).toStrictEqual({ name: 'Foo', value: 42 }); + }); + + it('returns undefined for non-existent items', async () => { + const mockStorage = createMockStorage(emptyState); + const controller = TestController.create(config, mockStorage); + + const item = await controller.getItem('nonexistent'); + + expect(item).toBeUndefined(); + }); + + it('reflects initial state count', async () => { + const initialState: TestState = { + items: { + a: { name: 'A', value: 1 }, + b: { name: 'B', value: 2 }, + }, + count: 2, + }; + const mockStorage = createMockStorage(initialState); + const controller = TestController.create(config, mockStorage); + + const count = await controller.getCount(); + + expect(count).toBe(2); + }); + }); + + describe('state updates', () => { + it('updates state through update method', async () => { + const mockStorage = createMockStorage(emptyState); + const controller = TestController.create(config, mockStorage); + + await controller.addItem('test', 'Test Item', 100); + + const item = await controller.getItem('test'); + expect(item).toStrictEqual({ name: 'Test Item', value: 100 }); + }); + + it('increments count when adding items', async () => { + const mockStorage = createMockStorage(emptyState); + const controller = TestController.create(config, mockStorage); + + await controller.addItem('a', 'Item A', 1); + await controller.addItem('b', 'Item B', 2); + + const count = await controller.getCount(); + expect(count).toBe(2); + }); + + it('decrements count when removing items', async () => { + const initialState: TestState = { + items: { + a: { name: 'A', value: 1 }, + b: { name: 'B', value: 2 }, + }, + count: 2, + }; + const mockStorage = createMockStorage(initialState); + const controller = TestController.create(config, mockStorage); + + await controller.removeItem('a'); + + const count = await controller.getCount(); + expect(count).toBe(1); + }); + + it('removes item from state', async () => { + const initialState: TestState = { + items: { foo: { name: 'Foo', value: 42 } }, + count: 1, + }; + const mockStorage = createMockStorage(initialState); + const controller = TestController.create(config, mockStorage); + + await controller.removeItem('foo'); + + const item = await controller.getItem('foo'); + expect(item).toBeUndefined(); + }); + + it('calls storage.update for each state modification', async () => { + const mockStorage = createMockStorage(emptyState); + const controller = TestController.create(config, mockStorage); + + await controller.addItem('a', 'A', 1); + await controller.addItem('b', 'B', 2); + await controller.removeItem('a'); + + expect(mockStorage.updateCalls).toHaveLength(3); + }); + }); + + describe('logging', () => { + it('logs through provided logger', async () => { + const mockStorage = createMockStorage(emptyState); + const controller = TestController.create(config, mockStorage); + + await controller.addItem('test', 'Test', 1); + + expect(mockLogger.info).toHaveBeenCalledWith('Adding item: test'); + }); + + it('logs remove operations', async () => { + const initialState: TestState = { + items: { foo: { name: 'Foo', value: 42 } }, + count: 1, + }; + const mockStorage = createMockStorage(initialState); + const controller = TestController.create(config, mockStorage); + + await controller.removeItem('foo'); + + expect(mockLogger.info).toHaveBeenCalledWith('Removing item: foo'); + }); + }); + + describe('getMethods', () => { + it('returns hardened exo with all methods', async () => { + const mockStorage = createMockStorage(emptyState); + const methods = TestController.create(config, mockStorage); + + expect(typeof methods.addItem).toBe('function'); + expect(typeof methods.removeItem).toBe('function'); + expect(typeof methods.getItem).toBe('function'); + expect(typeof methods.getCount).toBe('function'); + }); + + it('methods work correctly through exo', async () => { + const mockStorage = createMockStorage(emptyState); + const methods = TestController.create(config, mockStorage); + + await methods.addItem('x', 'X', 10); + const item = await methods.getItem('x'); + const count = await methods.getCount(); + + expect(item).toStrictEqual({ name: 'X', value: 10 }); + expect(count).toBe(1); + }); + }); +}); diff --git a/packages/omnium-gatherum/src/controllers/base-controller.ts b/packages/omnium-gatherum/src/controllers/base-controller.ts new file mode 100644 index 000000000..c69a52b04 --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/base-controller.ts @@ -0,0 +1,131 @@ +import type { Logger } from '@metamask/logger'; +import type { Json } from '@metamask/utils'; + +import type { ControllerStorage } from './storage/controller-storage.ts'; + +/** + * Base type for controller methods. + * Controllers expose their public API through a methods object. + */ +export type ControllerMethods = Record unknown>; + +/** + * Configuration passed to all controllers during initialization. + */ +export type ControllerConfig = { + logger: Logger; +}; + +/** + * Abstract base class for controllers. + * + * Provides state management via ControllerStorage with: + * - Synchronous state access via `this.state` + * - Async state updates via `this.update()` + * - Automatic persistence handled by storage layer + * + * Subclasses must: + * - Call `super()` in constructor with name, storage, and logger + * - Call `harden(this)` at the end of their constructor + * - Implement `getMethods()` to return a hardened exo with public API + * + * @template ControllerName - Literal string type for the controller name + * @template State - The state object shape (must be JSON-serializable) + * @template Methods - The public method interface + * + * @example + * ```typescript + * class MyController extends Controller<'MyController', MyState, MyMethods> { + * private constructor(storage: ControllerStorage, logger: Logger) { + * super('MyController', storage, logger); + * harden(this); + * } + * + * static create(config: ControllerConfig, deps: MyDeps): MyMethods { + * const controller = new MyController(deps.storage, config.logger); + * return controller.getMethods(); + * } + * + * getMethods(): MyMethods { + * return makeDefaultExo('MyController', { ... }); + * } + * } + * ``` + */ +export abstract class Controller< + ControllerName extends string, + State extends Record, + Methods extends ControllerMethods, +> { + readonly #name: ControllerName; + + readonly #storage: ControllerStorage; + + readonly #logger: Logger; + + /** + * Protected constructor - subclasses must call this via super(). + * + * @param name - Controller name for debugging/logging. + * @param storage - ControllerStorage instance for state management. + * @param logger - Logger instance. + */ + protected constructor( + name: ControllerName, + storage: ControllerStorage, + logger: Logger, + ) { + this.#name = name; + this.#storage = storage; + this.#logger = logger; + // Note: Subclass must call harden(this) after its own initialization + } + + /** + * Controller name for debugging/logging. + * + * @returns The controller name. + */ + protected get name(): ControllerName { + return this.#name; + } + + /** + * Current state (readonly). + * Provides synchronous access to in-memory state. + * + * @returns The current readonly state. + */ + protected get state(): Readonly { + return this.#storage.state; + } + + /** + * Logger instance for this controller. + * + * @returns The logger instance. + */ + protected get logger(): Logger { + return this.#logger; + } + + /** + * Update state using an immer producer function. + * Persistence is handled automatically by the storage layer. + * + * @param producer - Function that mutates a draft of the state. + * @returns Promise that resolves when changes are persisted. + */ + protected async update(producer: (draft: State) => void): Promise { + await this.#storage.update(producer); + } + + /** + * Returns the hardened exo with public methods. + * Subclasses implement this to define their public interface. + * + * @returns A hardened exo object with the controller's public methods. + */ + abstract makeFacet(): Methods; +} +harden(Controller); diff --git a/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.test.ts b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.test.ts index 23b99df3a..7d61afd79 100644 --- a/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.test.ts +++ b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { makeCapletController } from './caplet-controller.ts'; +import { CapletController } from './caplet-controller.ts'; import type { CapletControllerState } from './caplet-controller.ts'; import type { CapletManifest } from './types.ts'; import type { ControllerStorage } from '../storage/controller-storage.ts'; @@ -48,7 +48,7 @@ const emptyState: CapletControllerState = { caplets: {}, }; -describe('makeCapletController', () => { +describe('CapletController.make', () => { const mockLogger = { info: vi.fn(), warn: vi.fn(), @@ -83,7 +83,7 @@ describe('makeCapletController', () => { describe('install', () => { it('installs a caplet successfully', async () => { const mockStorage = createMockStorage(emptyState); - const controller = makeCapletController(config, { + const controller = CapletController.make(config, { storage: mockStorage, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, @@ -99,7 +99,7 @@ describe('makeCapletController', () => { it('validates the manifest', async () => { const mockStorage = createMockStorage(emptyState); - const controller = makeCapletController(config, { + const controller = CapletController.make(config, { storage: mockStorage, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, @@ -123,7 +123,7 @@ describe('makeCapletController', () => { }, }; const mockStorage = createMockStorage(stateWithCaplet); - const controller = makeCapletController(config, { + const controller = CapletController.make(config, { storage: mockStorage, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, @@ -136,7 +136,7 @@ describe('makeCapletController', () => { it('launches subcluster with correct config', async () => { const mockStorage = createMockStorage(emptyState); - const controller = makeCapletController(config, { + const controller = CapletController.make(config, { storage: mockStorage, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, @@ -159,7 +159,7 @@ describe('makeCapletController', () => { vi.setSystemTime(new Date('2024-01-15T12:00:00Z')); const mockStorage = createMockStorage(emptyState); - const controller = makeCapletController(config, { + const controller = CapletController.make(config, { storage: mockStorage, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, @@ -187,7 +187,7 @@ describe('makeCapletController', () => { }, }; const mockStorage = createMockStorage(stateWithOtherCaplet); - const controller = makeCapletController(config, { + const controller = CapletController.make(config, { storage: mockStorage, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, @@ -203,7 +203,7 @@ describe('makeCapletController', () => { it('logs installation progress', async () => { const mockStorage = createMockStorage(emptyState); - const controller = makeCapletController(config, { + const controller = CapletController.make(config, { storage: mockStorage, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, @@ -232,7 +232,7 @@ describe('makeCapletController', () => { }, }; const mockStorage = createMockStorage(stateWithCaplet); - const controller = makeCapletController(config, { + const controller = CapletController.make(config, { storage: mockStorage, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, @@ -245,7 +245,7 @@ describe('makeCapletController', () => { it('throws if caplet not found', async () => { const mockStorage = createMockStorage(emptyState); - const controller = makeCapletController(config, { + const controller = CapletController.make(config, { storage: mockStorage, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, @@ -267,7 +267,7 @@ describe('makeCapletController', () => { }, }; const mockStorage = createMockStorage(stateWithCaplet); - const controller = makeCapletController(config, { + const controller = CapletController.make(config, { storage: mockStorage, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, @@ -294,7 +294,7 @@ describe('makeCapletController', () => { }, }; const mockStorage = createMockStorage(stateWithCaplets); - const controller = makeCapletController(config, { + const controller = CapletController.make(config, { storage: mockStorage, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, @@ -318,7 +318,7 @@ describe('makeCapletController', () => { }, }; const mockStorage = createMockStorage(stateWithCaplet); - const controller = makeCapletController(config, { + const controller = CapletController.make(config, { storage: mockStorage, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, @@ -338,7 +338,7 @@ describe('makeCapletController', () => { describe('list', () => { it('returns empty array when no caplets installed', async () => { const mockStorage = createMockStorage(emptyState); - const controller = makeCapletController(config, { + const controller = CapletController.make(config, { storage: mockStorage, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, @@ -370,7 +370,7 @@ describe('makeCapletController', () => { }, }; const mockStorage = createMockStorage(stateWithCaplets); - const controller = makeCapletController(config, { + const controller = CapletController.make(config, { storage: mockStorage, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, @@ -404,7 +404,7 @@ describe('makeCapletController', () => { }, }; const mockStorage = createMockStorage(stateWithCaplet); - const controller = makeCapletController(config, { + const controller = CapletController.make(config, { storage: mockStorage, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, @@ -421,7 +421,7 @@ describe('makeCapletController', () => { it('returns undefined if caplet not found', async () => { const mockStorage = createMockStorage(emptyState); - const controller = makeCapletController(config, { + const controller = CapletController.make(config, { storage: mockStorage, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, @@ -445,7 +445,7 @@ describe('makeCapletController', () => { }, }; const mockStorage = createMockStorage(stateWithCaplet); - const controller = makeCapletController(config, { + const controller = CapletController.make(config, { storage: mockStorage, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, @@ -468,7 +468,7 @@ describe('makeCapletController', () => { }, }; const mockStorage = createMockStorage(stateWithCaplet); - const controller = makeCapletController(config, { + const controller = CapletController.make(config, { storage: mockStorage, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, @@ -501,7 +501,7 @@ describe('makeCapletController', () => { }, }; const mockStorage = createMockStorage(stateWithCaplets); - const controller = makeCapletController(config, { + const controller = CapletController.make(config, { storage: mockStorage, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, diff --git a/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts index 8c7b8a700..584694631 100644 --- a/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts +++ b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts @@ -1,4 +1,5 @@ import { makeDefaultExo } from '@metamask/kernel-utils/exo'; +import type { Logger } from '@metamask/logger'; import type { ClusterConfig } from '@metamask/ocap-kernel'; import type { @@ -9,8 +10,9 @@ import type { LaunchResult, } from './types.ts'; import { isCapletManifest } from './types.ts'; +import { Controller } from '../base-controller.ts'; +import type { ControllerConfig } from '../base-controller.ts'; import type { ControllerStorage } from '../storage/controller-storage.ts'; -import type { ControllerConfig } from '../types.ts'; /** * Caplet controller persistent state. @@ -25,7 +27,7 @@ export type CapletControllerState = { /** * Methods exposed by the CapletController. */ -export type CapletControllerMethods = { +export type CapletControllerFacet = { /** * Install a caplet. * @@ -83,122 +85,197 @@ export type CapletControllerDeps = { }; /** - * Create the CapletController. + * Controller for managing caplet lifecycle. * - * The CapletController manages the lifecycle of installed caplets: + * The CapletController manages: * - Installing caplets (validating manifest, launching subcluster, storing metadata) * - Uninstalling caplets (terminating subcluster, removing metadata) * - Querying installed caplets - * - * @param config - Controller configuration. - * @param deps - Controller dependencies (attenuated for POLA). - * @returns A hardened CapletController exo. */ -export function makeCapletController( - config: ControllerConfig, - deps: CapletControllerDeps, -): CapletControllerMethods { - const { logger } = config; - const { storage, launchSubcluster, terminateSubcluster } = deps; +export class CapletController extends Controller< + 'CapletController', + CapletControllerState, + CapletControllerFacet +> { + readonly #launchSubcluster: (config: ClusterConfig) => Promise; + + readonly #terminateSubcluster: (subclusterId: string) => Promise; /** - * Get an installed caplet by ID (synchronous - reads from in-memory state). + * Private constructor - use static create() method. * - * @param capletId - The caplet ID to retrieve. - * @returns The installed caplet or undefined if not found. + * @param storage - ControllerStorage for caplet state. + * @param logger - Logger instance. + * @param launchSubcluster - Function to launch a subcluster. + * @param terminateSubcluster - Function to terminate a subcluster. */ - const getCaplet = (capletId: CapletId): InstalledCaplet | undefined => { - return storage.state.caplets[capletId]; - }; + // eslint-disable-next-line no-restricted-syntax -- TypeScript doesn't support # for constructors + private constructor( + storage: ControllerStorage, + logger: Logger, + launchSubcluster: (config: ClusterConfig) => Promise, + terminateSubcluster: (subclusterId: string) => Promise, + ) { + super('CapletController', storage, logger); + this.#launchSubcluster = launchSubcluster; + this.#terminateSubcluster = terminateSubcluster; + harden(this); + } /** - * Get all installed caplets (synchronous - reads from in-memory state). + * Create a CapletController and return its public methods. * - * @returns Array of all installed caplets. + * @param config - Controller configuration. + * @param deps - Controller dependencies (attenuated for POLA). + * @returns A hardened CapletController exo. + */ + static make( + config: ControllerConfig, + deps: CapletControllerDeps, + ): CapletControllerFacet { + const controller = new CapletController( + deps.storage, + config.logger, + deps.launchSubcluster, + deps.terminateSubcluster, + ); + return controller.makeFacet(); + } + + /** + * Returns the hardened exo with public methods. + * + * @returns A hardened exo object with the controller's public methods. + */ + makeFacet(): CapletControllerFacet { + return makeDefaultExo('CapletController', { + install: async ( + manifest: CapletManifest, + _bundle?: unknown, + ): Promise => { + return this.#install(manifest, _bundle); + }, + uninstall: async (capletId: CapletId): Promise => { + return this.#uninstall(capletId); + }, + list: async (): Promise => { + return this.#list(); + }, + get: async (capletId: CapletId): Promise => { + return this.#get(capletId); + }, + getByService: async ( + serviceName: string, + ): Promise => { + return this.#getByService(serviceName); + }, + }); + } + + /** + * Install a caplet. + * + * @param manifest - The caplet manifest. + * @param _bundle - The caplet bundle (currently unused). + * @returns The installation result. */ - const listCaplets = (): InstalledCaplet[] => { - return Object.values(storage.state.caplets); - }; - - return makeDefaultExo('CapletController', { - async install( - manifest: CapletManifest, - _bundle?: unknown, - ): Promise { - const { id } = manifest; - logger.info(`Installing caplet: ${id}`); - - // Validate manifest - if (!isCapletManifest(manifest)) { - throw new Error(`Invalid caplet manifest for ${id}`); - } - - // Check if already installed - if (storage.state.caplets[id] !== undefined) { - throw new Error(`Caplet ${id} is already installed`); - } - - // Create cluster config for this caplet - const clusterConfig: ClusterConfig = { - bootstrap: id, - vats: { - [id]: { - bundleSpec: manifest.bundleSpec, - }, + async #install( + manifest: CapletManifest, + _bundle?: unknown, + ): Promise { + const { id } = manifest; + this.logger.info(`Installing caplet: ${id}`); + + // Validate manifest + if (!isCapletManifest(manifest)) { + throw new Error(`Invalid caplet manifest for ${id}`); + } + + // Check if already installed + if (this.state.caplets[id] !== undefined) { + throw new Error(`Caplet ${id} is already installed`); + } + + // Create cluster config for this caplet + const clusterConfig: ClusterConfig = { + bootstrap: id, + vats: { + [id]: { + bundleSpec: manifest.bundleSpec, }, + }, + }; + + // Launch subcluster + const { subclusterId } = await this.#launchSubcluster(clusterConfig); + + // Store caplet data + await this.update((draft) => { + draft.caplets[id] = { + manifest, + subclusterId, + installedAt: Date.now(), }; + }); - // Launch subcluster - const { subclusterId } = await launchSubcluster(clusterConfig); - - // Store caplet data - await storage.update((draft) => { - draft.caplets[id] = { - manifest, - subclusterId, - installedAt: Date.now(), - }; - }); - - logger.info(`Caplet ${id} installed with subcluster ${subclusterId}`); - return { capletId: id, subclusterId }; - }, - - async uninstall(capletId: CapletId): Promise { - logger.info(`Uninstalling caplet: ${capletId}`); - - const caplet = storage.state.caplets[capletId]; - if (caplet === undefined) { - throw new Error(`Caplet ${capletId} not found`); - } - - // Terminate the subcluster - await terminateSubcluster(caplet.subclusterId); - - // Remove from storage - await storage.update((draft) => { - delete draft.caplets[capletId]; - }); - - logger.info(`Caplet ${capletId} uninstalled`); - }, - - async list(): Promise { - return listCaplets(); - }, - - async get(capletId: CapletId): Promise { - return getCaplet(capletId); - }, - - async getByService( - serviceName: string, - ): Promise { - const caplets = listCaplets(); - return caplets.find((caplet: InstalledCaplet) => - caplet.manifest.providedServices.includes(serviceName), - ); - }, - }); + this.logger.info(`Caplet ${id} installed with subcluster ${subclusterId}`); + return { capletId: id, subclusterId }; + } + + /** + * Uninstall a caplet. + * + * @param capletId - The ID of the caplet to uninstall. + */ + async #uninstall(capletId: CapletId): Promise { + this.logger.info(`Uninstalling caplet: ${capletId}`); + + const caplet = this.state.caplets[capletId]; + if (caplet === undefined) { + throw new Error(`Caplet ${capletId} not found`); + } + + // Terminate the subcluster + await this.#terminateSubcluster(caplet.subclusterId); + + // Remove from storage + await this.update((draft) => { + delete draft.caplets[capletId]; + }); + + this.logger.info(`Caplet ${capletId} uninstalled`); + } + + /** + * Get all installed caplets. + * + * @returns Array of all installed caplets. + */ + #list(): InstalledCaplet[] { + return Object.values(this.state.caplets); + } + + /** + * Get an installed caplet by ID. + * + * @param capletId - The caplet ID to retrieve. + * @returns The installed caplet or undefined if not found. + */ + #get(capletId: CapletId): InstalledCaplet | undefined { + return this.state.caplets[capletId]; + } + + /** + * Find a caplet that provides a specific service. + * + * @param serviceName - The service name to search for. + * @returns The installed caplet or undefined if not found. + */ + #getByService(serviceName: string): InstalledCaplet | undefined { + const caplets = this.#list(); + return caplets.find((caplet: InstalledCaplet) => + caplet.manifest.providedServices.includes(serviceName), + ); + } } -harden(makeCapletController); +harden(CapletController); diff --git a/packages/omnium-gatherum/src/controllers/caplet/index.ts b/packages/omnium-gatherum/src/controllers/caplet/index.ts index e0cb3f5cf..af216b869 100644 --- a/packages/omnium-gatherum/src/controllers/caplet/index.ts +++ b/packages/omnium-gatherum/src/controllers/caplet/index.ts @@ -16,8 +16,8 @@ export { CapletManifestStruct, } from './types.ts'; export type { - CapletControllerMethods, + CapletControllerFacet, CapletControllerDeps, CapletControllerState, } from './caplet-controller.ts'; -export { makeCapletController } from './caplet-controller.ts'; +export { CapletController } from './caplet-controller.ts'; diff --git a/packages/omnium-gatherum/src/controllers/facet.ts b/packages/omnium-gatherum/src/controllers/facet.ts index 2ec6dc269..1825ceebd 100644 --- a/packages/omnium-gatherum/src/controllers/facet.ts +++ b/packages/omnium-gatherum/src/controllers/facet.ts @@ -35,7 +35,7 @@ type FacetMethods> = Methods & { * * // CapletController only needs get/set, not clear/getAll * const storageFacet = makeFacet('CapletStorage', storageController, ['get', 'set']); - * const capletController = makeCapletController({ storage: storageFacet }); + * const capletController = CapletController.make({ storage: storageFacet }); * ``` */ export function makeFacet< diff --git a/packages/omnium-gatherum/src/controllers/index.ts b/packages/omnium-gatherum/src/controllers/index.ts index 22abb8f00..cc6326308 100644 --- a/packages/omnium-gatherum/src/controllers/index.ts +++ b/packages/omnium-gatherum/src/controllers/index.ts @@ -1,5 +1,6 @@ -// Base types -export type { ControllerConfig, FacetOf } from './types.ts'; +// Base controller +export { Controller } from './base-controller.ts'; +export type { ControllerConfig, ControllerMethods, FacetOf } from './types.ts'; export { makeFacet } from './facet.ts'; // Storage @@ -24,7 +25,7 @@ export type { InstallResult, LaunchResult, CapletControllerState, - CapletControllerMethods, + CapletControllerFacet, CapletControllerDeps, } from './caplet/index.ts'; export { @@ -35,5 +36,5 @@ export { CapletIdStruct, SemVerStruct, CapletManifestStruct, - makeCapletController, + CapletController, } from './caplet/index.ts'; diff --git a/packages/omnium-gatherum/src/controllers/types.ts b/packages/omnium-gatherum/src/controllers/types.ts index 2c4cfc890..84f2287e4 100644 --- a/packages/omnium-gatherum/src/controllers/types.ts +++ b/packages/omnium-gatherum/src/controllers/types.ts @@ -1,12 +1,7 @@ import type { Methods } from '@endo/exo'; -import type { Logger } from '@metamask/logger'; -/** - * Configuration passed to all controllers during initialization. - */ -export type ControllerConfig = { - logger: Logger; -}; +// Re-export from base-controller for backward compatibility +export type { ControllerConfig, ControllerMethods } from './base-controller.ts'; /** * Type helper for defining facet interfaces. diff --git a/packages/omnium-gatherum/src/manifest.json b/packages/omnium-gatherum/src/manifest.json index 8f815cecd..653d0b8bd 100644 --- a/packages/omnium-gatherum/src/manifest.json +++ b/packages/omnium-gatherum/src/manifest.json @@ -10,7 +10,7 @@ "action": { "default_popup": "popup.html" }, - "permissions": ["offscreen", "unlimitedStorage"], + "permissions": ["offscreen", "storage", "unlimitedStorage"], "sandbox": { "pages": ["iframe.html"] }, diff --git a/packages/omnium-gatherum/src/types/semver.d.ts b/packages/omnium-gatherum/src/types/semver.d.ts new file mode 100644 index 000000000..9a6ab706d --- /dev/null +++ b/packages/omnium-gatherum/src/types/semver.d.ts @@ -0,0 +1,7 @@ +declare module 'semver/functions/valid' { + function valid( + version: string | null | undefined, + optionsOrLoose?: boolean | { loose?: boolean; includePrerelease?: boolean }, + ): string | null; + export default valid; +} diff --git a/yarn.lock b/yarn.lock index 196090ffc..640791b7e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3883,6 +3883,7 @@ __metadata: "@types/chrome": "npm:^0.0.313" "@types/react": "npm:^17.0.11" "@types/react-dom": "npm:^17.0.11" + "@types/semver": "npm:^7.7.1" "@types/webextension-polyfill": "npm:^0" "@typescript-eslint/eslint-plugin": "npm:^8.29.0" "@typescript-eslint/parser": "npm:^8.29.0" @@ -5229,10 +5230,10 @@ __metadata: languageName: node linkType: hard -"@types/semver@npm:^7.3.6": - version: 7.7.0 - resolution: "@types/semver@npm:7.7.0" - checksum: 10/ee4514c6c852b1c38f951239db02f9edeea39f5310fad9396a00b51efa2a2d96b3dfca1ae84c88181ea5b7157c57d32d7ef94edacee36fbf975546396b85ba5b +"@types/semver@npm:^7.3.6, @types/semver@npm:^7.7.1": + version: 7.7.1 + resolution: "@types/semver@npm:7.7.1" + checksum: 10/8f09e7e6ca3ded67d78ba7a8f7535c8d9cf8ced83c52e7f3ac3c281fe8c689c3fe475d199d94390dc04fc681d51f2358b430bb7b2e21c62de24f2bee2c719068 languageName: node linkType: hard From cd5adbd401d05e678330f87e52d2ebc8412f363d Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Fri, 9 Jan 2026 15:38:55 -0800 Subject: [PATCH 11/11] refactor(omnium): Refactor ControllerStorage with debounced persistence - Convert ControllerStorage from factory to class with static make() method - Implement synchronous update() with debounced fire-and-forget persistence - Fix critical debounce bug: accumulate modified keys across debounce window - Implement bounded latency (timer not reset, max delay = one debounce interval) - Add immediate writes when idle > debounceMs for better responsiveness - Add clear() and clearState() methods to reset storage to defaults - Remove old namespaced-storage implementation - Refactor all tests to use actual ControllerStorage with mock adapters - Add shared makeMockStorageAdapter() utility in test/utils.ts - Update controllers to create their own storage from adapters Co-Authored-By: Claude Sonnet 4.5 --- packages/omnium-gatherum/src/background.ts | 22 +- .../src/controllers/base-controller.test.ts | 234 ++++++----- .../src/controllers/base-controller.ts | 21 +- .../caplet/caplet-controller.test.ts | 367 ++++++++---------- .../controllers/caplet/caplet-controller.ts | 27 +- .../omnium-gatherum/src/controllers/index.ts | 4 +- .../storage/controller-storage.test.ts | 301 +++++++++++--- .../controllers/storage/controller-storage.ts | 341 ++++++++++------ .../src/controllers/storage/index.ts | 8 +- .../storage/namespaced-storage.test.ts | 156 -------- .../controllers/storage/namespaced-storage.ts | 52 --- .../omnium-gatherum/test/e2e/smoke.test.ts | 2 +- .../test/{helpers.ts => e2e/utils.ts} | 2 +- packages/omnium-gatherum/test/utils.ts | 31 ++ 14 files changed, 816 insertions(+), 752 deletions(-) delete mode 100644 packages/omnium-gatherum/src/controllers/storage/namespaced-storage.test.ts delete mode 100644 packages/omnium-gatherum/src/controllers/storage/namespaced-storage.ts rename packages/omnium-gatherum/test/{helpers.ts => e2e/utils.ts} (96%) create mode 100644 packages/omnium-gatherum/test/utils.ts diff --git a/packages/omnium-gatherum/src/background.ts b/packages/omnium-gatherum/src/background.ts index 784c9fa7f..8b412498b 100644 --- a/packages/omnium-gatherum/src/background.ts +++ b/packages/omnium-gatherum/src/background.ts @@ -18,13 +18,8 @@ import { ChromeRuntimeDuplexStream } from '@metamask/streams/browser'; import { CapletController, makeChromeStorageAdapter, - makeControllerStorage, -} from './controllers/index.ts'; -import type { - CapletControllerState, - CapletManifest, - LaunchResult, } from './controllers/index.ts'; +import type { CapletManifest, LaunchResult } from './controllers/index.ts'; defineGlobals(); @@ -124,22 +119,15 @@ async function main(): Promise { return kernelP; }; - // Create storage adapter and state storage for caplets + // Create storage adapter const storageAdapter = makeChromeStorageAdapter(); - const defaultCapletState: CapletControllerState = { - caplets: {}, - }; - const capletStorage = await makeControllerStorage({ - namespace: 'caplet', - adapter: storageAdapter, - defaultState: defaultCapletState, - }); // Create CapletController with attenuated kernel access - const capletController = CapletController.make( + // Controller creates its own storage internally + const capletController = await CapletController.make( { logger: logger.subLogger({ tags: ['caplet'] }) }, { - storage: capletStorage, + adapter: storageAdapter, // Wrap launchSubcluster to return subclusterId launchSubcluster: async ( config: ClusterConfig, diff --git a/packages/omnium-gatherum/src/controllers/base-controller.test.ts b/packages/omnium-gatherum/src/controllers/base-controller.test.ts index d39abb533..1bfa82f9b 100644 --- a/packages/omnium-gatherum/src/controllers/base-controller.test.ts +++ b/packages/omnium-gatherum/src/controllers/base-controller.test.ts @@ -4,7 +4,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Controller } from './base-controller.ts'; import type { ControllerConfig } from './base-controller.ts'; -import type { ControllerStorage } from './storage/controller-storage.ts'; +import { ControllerStorage } from './storage/controller-storage.ts'; +import type { StorageAdapter } from './storage/types.ts'; +import { makeMockStorageAdapter } from '../../test/utils.ts'; /** * Test state for the concrete test controller. @@ -22,6 +24,8 @@ type TestMethods = { removeItem: (id: string) => Promise; getItem: (id: string) => Promise<{ name: string; value: number } | undefined>; getCount: () => Promise; + clearState: () => void; + getState: () => Readonly; }; /** @@ -38,10 +42,21 @@ class TestController extends Controller< harden(this); } - static create( + static async make( config: ControllerConfig, - storage: ControllerStorage, - ): TestMethods { + adapter: StorageAdapter, + ): Promise { + const storage = await ControllerStorage.make({ + namespace: 'test', + adapter, + defaultState: { + items: {}, + count: 0, + }, + logger: config.logger, + debounceMs: 0, + }); + const controller = new TestController(storage, config.logger); return controller.makeFacet(); } @@ -54,14 +69,14 @@ class TestController extends Controller< value: number, ): Promise => { this.logger.info(`Adding item: ${id}`); - await this.update((draft) => { + this.update((draft) => { draft.items[id] = { name, value }; draft.count += 1; }); }, removeItem: async (id: string): Promise => { this.logger.info(`Removing item: ${id}`); - await this.update((draft) => { + this.update((draft) => { delete draft.items[id]; draft.count -= 1; }); @@ -74,49 +89,17 @@ class TestController extends Controller< getCount: async (): Promise => { return this.state.count; }, + clearState: (): void => { + this.clearState(); + }, + getState: (): Readonly => { + return this.state; + }, }); } } harden(TestController); -/** - * Create a mock ControllerStorage for testing. - * - * @param initialState - The initial state for the mock storage. - * @returns A mock ControllerStorage instance with update tracking. - */ -function createMockStorage( - initialState: TestState, -): ControllerStorage & { updateCalls: (() => void)[] } { - let currentState = { ...initialState }; - const updateCalls: (() => void)[] = []; - - return { - get state(): Readonly { - return harden({ ...currentState }); - }, - - async update(producer: (draft: TestState) => void): Promise { - // Create a mutable draft - const draft = JSON.parse(JSON.stringify(currentState)) as TestState; - producer(draft); - currentState = draft; - updateCalls.push(() => producer(draft)); - }, - - async reload(): Promise { - // No-op for tests - }, - - updateCalls, - }; -} - -const emptyState: TestState = { - items: {}, - count: 0, -}; - describe('Controller', () => { const mockLogger = { info: vi.fn(), @@ -136,12 +119,11 @@ describe('Controller', () => { describe('state access', () => { it('provides read-only access to state', async () => { - const initialState: TestState = { - items: { foo: { name: 'Foo', value: 42 } }, - count: 1, - }; - const mockStorage = createMockStorage(initialState); - const controller = TestController.create(config, mockStorage); + const mockAdapter = makeMockStorageAdapter(); + await mockAdapter.set('test.items', { foo: { name: 'Foo', value: 42 } }); + await mockAdapter.set('test.count', 1); + + const controller = await TestController.make(config, mockAdapter); const item = await controller.getItem('foo'); @@ -149,8 +131,8 @@ describe('Controller', () => { }); it('returns undefined for non-existent items', async () => { - const mockStorage = createMockStorage(emptyState); - const controller = TestController.create(config, mockStorage); + const mockAdapter = makeMockStorageAdapter(); + const controller = await TestController.make(config, mockAdapter); const item = await controller.getItem('nonexistent'); @@ -158,15 +140,14 @@ describe('Controller', () => { }); it('reflects initial state count', async () => { - const initialState: TestState = { - items: { - a: { name: 'A', value: 1 }, - b: { name: 'B', value: 2 }, - }, - count: 2, - }; - const mockStorage = createMockStorage(initialState); - const controller = TestController.create(config, mockStorage); + const mockAdapter = makeMockStorageAdapter(); + await mockAdapter.set('test.items', { + a: { name: 'A', value: 1 }, + b: { name: 'B', value: 2 }, + }); + await mockAdapter.set('test.count', 2); + + const controller = await TestController.make(config, mockAdapter); const count = await controller.getCount(); @@ -176,8 +157,8 @@ describe('Controller', () => { describe('state updates', () => { it('updates state through update method', async () => { - const mockStorage = createMockStorage(emptyState); - const controller = TestController.create(config, mockStorage); + const mockAdapter = makeMockStorageAdapter(); + const controller = await TestController.make(config, mockAdapter); await controller.addItem('test', 'Test Item', 100); @@ -186,8 +167,8 @@ describe('Controller', () => { }); it('increments count when adding items', async () => { - const mockStorage = createMockStorage(emptyState); - const controller = TestController.create(config, mockStorage); + const mockAdapter = makeMockStorageAdapter(); + const controller = await TestController.make(config, mockAdapter); await controller.addItem('a', 'Item A', 1); await controller.addItem('b', 'Item B', 2); @@ -197,15 +178,14 @@ describe('Controller', () => { }); it('decrements count when removing items', async () => { - const initialState: TestState = { - items: { - a: { name: 'A', value: 1 }, - b: { name: 'B', value: 2 }, - }, - count: 2, - }; - const mockStorage = createMockStorage(initialState); - const controller = TestController.create(config, mockStorage); + const mockAdapter = makeMockStorageAdapter(); + await mockAdapter.set('test.items', { + a: { name: 'A', value: 1 }, + b: { name: 'B', value: 2 }, + }); + await mockAdapter.set('test.count', 2); + + const controller = await TestController.make(config, mockAdapter); await controller.removeItem('a'); @@ -214,12 +194,11 @@ describe('Controller', () => { }); it('removes item from state', async () => { - const initialState: TestState = { - items: { foo: { name: 'Foo', value: 42 } }, - count: 1, - }; - const mockStorage = createMockStorage(initialState); - const controller = TestController.create(config, mockStorage); + const mockAdapter = makeMockStorageAdapter(); + await mockAdapter.set('test.items', { foo: { name: 'Foo', value: 42 } }); + await mockAdapter.set('test.count', 1); + + const controller = await TestController.make(config, mockAdapter); await controller.removeItem('foo'); @@ -227,22 +206,29 @@ describe('Controller', () => { expect(item).toBeUndefined(); }); - it('calls storage.update for each state modification', async () => { - const mockStorage = createMockStorage(emptyState); - const controller = TestController.create(config, mockStorage); + it('persists state modifications to storage', async () => { + const mockAdapter = makeMockStorageAdapter(); + const controller = await TestController.make(config, mockAdapter); await controller.addItem('a', 'A', 1); await controller.addItem('b', 'B', 2); await controller.removeItem('a'); - expect(mockStorage.updateCalls).toHaveLength(3); + // Wait for debounced persistence + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Check that state was persisted + const items = await mockAdapter.get('test.items'); + const count = await mockAdapter.get('test.count'); + expect(items).toStrictEqual({ b: { name: 'B', value: 2 } }); + expect(count).toBe(1); }); }); describe('logging', () => { it('logs through provided logger', async () => { - const mockStorage = createMockStorage(emptyState); - const controller = TestController.create(config, mockStorage); + const mockAdapter = makeMockStorageAdapter(); + const controller = await TestController.make(config, mockAdapter); await controller.addItem('test', 'Test', 1); @@ -250,12 +236,11 @@ describe('Controller', () => { }); it('logs remove operations', async () => { - const initialState: TestState = { - items: { foo: { name: 'Foo', value: 42 } }, - count: 1, - }; - const mockStorage = createMockStorage(initialState); - const controller = TestController.create(config, mockStorage); + const mockAdapter = makeMockStorageAdapter(); + await mockAdapter.set('test.items', { foo: { name: 'Foo', value: 42 } }); + await mockAdapter.set('test.count', 1); + + const controller = await TestController.make(config, mockAdapter); await controller.removeItem('foo'); @@ -263,24 +248,63 @@ describe('Controller', () => { }); }); - describe('getMethods', () => { - it('returns hardened exo with all methods', async () => { - const mockStorage = createMockStorage(emptyState); - const methods = TestController.create(config, mockStorage); + describe('clearState', () => { + it('clears state through clearState method', async () => { + const mockAdapter = makeMockStorageAdapter(); + const controller = await TestController.make(config, mockAdapter); + await controller.addItem('a', 'A', 1); + + const stateBefore = controller.getState(); + expect(stateBefore.items).toStrictEqual({ a: { name: 'A', value: 1 } }); + expect(stateBefore.count).toBe(1); - expect(typeof methods.addItem).toBe('function'); - expect(typeof methods.removeItem).toBe('function'); - expect(typeof methods.getItem).toBe('function'); - expect(typeof methods.getCount).toBe('function'); + controller.clearState(); + + const stateAfter = controller.getState(); + expect(stateAfter.items).toStrictEqual({}); + expect(stateAfter.count).toBe(0); + }); + + it('persists cleared state', async () => { + const mockAdapter = makeMockStorageAdapter(); + const controller = await TestController.make(config, mockAdapter); + await controller.addItem('a', 'A', 1); + + // Wait for persistence + await new Promise((resolve) => setTimeout(resolve, 10)); + + controller.clearState(); + + // Wait for persistence + await new Promise((resolve) => setTimeout(resolve, 10)); + + const items = await mockAdapter.get('test.items'); + const count = await mockAdapter.get('test.count'); + expect(items).toStrictEqual({}); + expect(count).toBe(0); + }); + }); + + describe('makeFacet', () => { + it('returns hardened exo with all methods', async () => { + const mockAdapter = makeMockStorageAdapter(); + const facet = await TestController.make(config, mockAdapter); + + expect(typeof facet.addItem).toBe('function'); + expect(typeof facet.removeItem).toBe('function'); + expect(typeof facet.getItem).toBe('function'); + expect(typeof facet.getCount).toBe('function'); + expect(typeof facet.clearState).toBe('function'); + expect(typeof facet.getState).toBe('function'); }); it('methods work correctly through exo', async () => { - const mockStorage = createMockStorage(emptyState); - const methods = TestController.create(config, mockStorage); + const mockAdapter = makeMockStorageAdapter(); + const facet = await TestController.make(config, mockAdapter); - await methods.addItem('x', 'X', 10); - const item = await methods.getItem('x'); - const count = await methods.getCount(); + await facet.addItem('x', 'X', 10); + const item = await facet.getItem('x'); + const count = await facet.getCount(); expect(item).toStrictEqual({ name: 'X', value: 10 }); expect(count).toBe(1); diff --git a/packages/omnium-gatherum/src/controllers/base-controller.ts b/packages/omnium-gatherum/src/controllers/base-controller.ts index c69a52b04..5b049576f 100644 --- a/packages/omnium-gatherum/src/controllers/base-controller.ts +++ b/packages/omnium-gatherum/src/controllers/base-controller.ts @@ -27,7 +27,7 @@ export type ControllerConfig = { * Subclasses must: * - Call `super()` in constructor with name, storage, and logger * - Call `harden(this)` at the end of their constructor - * - Implement `getMethods()` to return a hardened exo with public API + * - Implement `makeFacet()` to return a hardened exo with public API * * @template ControllerName - Literal string type for the controller name * @template State - The state object shape (must be JSON-serializable) @@ -43,10 +43,10 @@ export type ControllerConfig = { * * static create(config: ControllerConfig, deps: MyDeps): MyMethods { * const controller = new MyController(deps.storage, config.logger); - * return controller.getMethods(); + * return controller.makeFacet(); * } * - * getMethods(): MyMethods { + * makeFacet(): MyMethods { * return makeDefaultExo('MyController', { ... }); * } * } @@ -111,13 +111,20 @@ export abstract class Controller< /** * Update state using an immer producer function. - * Persistence is handled automatically by the storage layer. + * State is updated synchronously in memory. + * Persistence is handled automatically by the storage layer (debounced). * * @param producer - Function that mutates a draft of the state. - * @returns Promise that resolves when changes are persisted. */ - protected async update(producer: (draft: State) => void): Promise { - await this.#storage.update(producer); + protected update(producer: (draft: State) => void): void { + this.#storage.update(producer); + } + + /** + * Clear storage and reset to default state. + */ + clearState(): void { + this.#storage.clear(); } /** diff --git a/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.test.ts b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.test.ts index 7d61afd79..ce483096b 100644 --- a/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.test.ts +++ b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.test.ts @@ -1,53 +1,26 @@ +import type { Json } from '@metamask/utils'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { CapletController } from './caplet-controller.ts'; -import type { CapletControllerState } from './caplet-controller.ts'; import type { CapletManifest } from './types.ts'; -import type { ControllerStorage } from '../storage/controller-storage.ts'; +import { makeMockStorageAdapter } from '../../../test/utils.ts'; +import type { StorageAdapter } from '../storage/types.ts'; import type { ControllerConfig } from '../types.ts'; /** - * Create a mock ControllerStorage for testing. - * Maintains in-memory state and tracks update calls. + * Seed a mock adapter with caplet controller state. * - * @param initialState - The initial state for the mock storage. - * @returns A mock ControllerStorage instance with update tracking. + * @param adapter - The adapter to seed. + * @param caplets - The caplets to pre-populate. + * @returns A promise that resolves when seeding is complete. */ -function createMockStorage( - initialState: CapletControllerState, -): ControllerStorage & { updateCalls: (() => void)[] } { - let currentState = { ...initialState }; - const updateCalls: (() => void)[] = []; - - return { - get state(): Readonly { - return harden({ ...currentState }); - }, - - async update( - producer: (draft: CapletControllerState) => void, - ): Promise { - // Create a mutable draft - const draft = JSON.parse( - JSON.stringify(currentState), - ) as CapletControllerState; - producer(draft); - currentState = draft; - updateCalls.push(() => producer(draft)); - }, - - async reload(): Promise { - // No-op for tests - }, - - updateCalls, - }; +async function seedAdapter( + adapter: StorageAdapter, + caplets: Record, +): Promise { + await adapter.set('caplet.caplets', caplets as Json); } -const emptyState: CapletControllerState = { - caplets: {}, -}; - describe('CapletController.make', () => { const mockLogger = { info: vi.fn(), @@ -82,9 +55,9 @@ describe('CapletController.make', () => { describe('install', () => { it('installs a caplet successfully', async () => { - const mockStorage = createMockStorage(emptyState); - const controller = CapletController.make(config, { - storage: mockStorage, + const mockAdapter = makeMockStorageAdapter(); + const controller = await CapletController.make(config, { + adapter: mockAdapter, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, }); @@ -98,9 +71,9 @@ describe('CapletController.make', () => { }); it('validates the manifest', async () => { - const mockStorage = createMockStorage(emptyState); - const controller = CapletController.make(config, { - storage: mockStorage, + const mockAdapter = makeMockStorageAdapter(); + const controller = await CapletController.make(config, { + adapter: mockAdapter, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, }); @@ -113,18 +86,16 @@ describe('CapletController.make', () => { }); it('throws if caplet already installed', async () => { - const stateWithCaplet: CapletControllerState = { - caplets: { - 'com.example.test': { - manifest: validManifest, - subclusterId: 'subcluster-123', - installedAt: 1000, - }, + const mockAdapter = makeMockStorageAdapter(); + await seedAdapter(mockAdapter, { + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-123', + installedAt: 1000, }, - }; - const mockStorage = createMockStorage(stateWithCaplet); - const controller = CapletController.make(config, { - storage: mockStorage, + }); + const controller = await CapletController.make(config, { + adapter: mockAdapter, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, }); @@ -135,9 +106,9 @@ describe('CapletController.make', () => { }); it('launches subcluster with correct config', async () => { - const mockStorage = createMockStorage(emptyState); - const controller = CapletController.make(config, { - storage: mockStorage, + const mockAdapter = makeMockStorageAdapter(); + const controller = await CapletController.make(config, { + adapter: mockAdapter, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, }); @@ -158,16 +129,16 @@ describe('CapletController.make', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2024-01-15T12:00:00Z')); - const mockStorage = createMockStorage(emptyState); - const controller = CapletController.make(config, { - storage: mockStorage, + const mockAdapter = makeMockStorageAdapter(); + const controller = await CapletController.make(config, { + adapter: mockAdapter, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, }); await controller.install(validManifest); - const caplet = mockStorage.state.caplets['com.example.test']; + const caplet = await controller.get('com.example.test'); expect(caplet).toBeDefined(); expect(caplet?.manifest).toStrictEqual(validManifest); expect(caplet?.subclusterId).toBe('subcluster-123'); @@ -177,34 +148,31 @@ describe('CapletController.make', () => { }); it('preserves existing caplets when installing', async () => { - const stateWithOtherCaplet: CapletControllerState = { - caplets: { - 'com.other.caplet': { - manifest: { ...validManifest, id: 'com.other.caplet' }, - subclusterId: 'subcluster-other', - installedAt: 500, - }, + const mockAdapter = makeMockStorageAdapter(); + await seedAdapter(mockAdapter, { + 'com.other.caplet': { + manifest: { ...validManifest, id: 'com.other.caplet' }, + subclusterId: 'subcluster-other', + installedAt: 500, }, - }; - const mockStorage = createMockStorage(stateWithOtherCaplet); - const controller = CapletController.make(config, { - storage: mockStorage, + }); + const controller = await CapletController.make(config, { + adapter: mockAdapter, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, }); await controller.install(validManifest); - expect(Object.keys(mockStorage.state.caplets)).toStrictEqual([ - 'com.other.caplet', - 'com.example.test', - ]); + const caplets = await controller.list(); + const capletIds = caplets.map((caplet) => caplet.manifest.id).sort(); + expect(capletIds).toStrictEqual(['com.example.test', 'com.other.caplet']); }); it('logs installation progress', async () => { - const mockStorage = createMockStorage(emptyState); - const controller = CapletController.make(config, { - storage: mockStorage, + const mockAdapter = makeMockStorageAdapter(); + const controller = await CapletController.make(config, { + adapter: mockAdapter, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, }); @@ -222,18 +190,16 @@ describe('CapletController.make', () => { describe('uninstall', () => { it('uninstalls a caplet successfully', async () => { - const stateWithCaplet: CapletControllerState = { - caplets: { - 'com.example.test': { - manifest: validManifest, - subclusterId: 'subcluster-123', - installedAt: 1000, - }, + const mockAdapter = makeMockStorageAdapter(); + await seedAdapter(mockAdapter, { + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-123', + installedAt: 1000, }, - }; - const mockStorage = createMockStorage(stateWithCaplet); - const controller = CapletController.make(config, { - storage: mockStorage, + }); + const controller = await CapletController.make(config, { + adapter: mockAdapter, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, }); @@ -244,9 +210,9 @@ describe('CapletController.make', () => { }); it('throws if caplet not found', async () => { - const mockStorage = createMockStorage(emptyState); - const controller = CapletController.make(config, { - storage: mockStorage, + const mockAdapter = makeMockStorageAdapter(); + const controller = await CapletController.make(config, { + adapter: mockAdapter, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, }); @@ -257,69 +223,64 @@ describe('CapletController.make', () => { }); it('removes caplet from state', async () => { - const stateWithCaplet: CapletControllerState = { - caplets: { - 'com.example.test': { - manifest: validManifest, - subclusterId: 'subcluster-123', - installedAt: 1000, - }, + const mockAdapter = makeMockStorageAdapter(); + await seedAdapter(mockAdapter, { + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-123', + installedAt: 1000, }, - }; - const mockStorage = createMockStorage(stateWithCaplet); - const controller = CapletController.make(config, { - storage: mockStorage, + }); + const controller = await CapletController.make(config, { + adapter: mockAdapter, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, }); await controller.uninstall('com.example.test'); - expect(mockStorage.state.caplets['com.example.test']).toBeUndefined(); + const caplet = await controller.get('com.example.test'); + expect(caplet).toBeUndefined(); }); it('preserves other caplets when uninstalling', async () => { - const stateWithCaplets: CapletControllerState = { - caplets: { - 'com.other.caplet': { - manifest: { ...validManifest, id: 'com.other.caplet' }, - subclusterId: 'subcluster-other', - installedAt: 500, - }, - 'com.example.test': { - manifest: validManifest, - subclusterId: 'subcluster-123', - installedAt: 1000, - }, + const mockAdapter = makeMockStorageAdapter(); + await seedAdapter(mockAdapter, { + 'com.other.caplet': { + manifest: { ...validManifest, id: 'com.other.caplet' }, + subclusterId: 'subcluster-other', + installedAt: 500, }, - }; - const mockStorage = createMockStorage(stateWithCaplets); - const controller = CapletController.make(config, { - storage: mockStorage, + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-123', + installedAt: 1000, + }, + }); + const controller = await CapletController.make(config, { + adapter: mockAdapter, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, }); await controller.uninstall('com.example.test'); - expect(Object.keys(mockStorage.state.caplets)).toStrictEqual([ - 'com.other.caplet', - ]); + const caplets = await controller.list(); + const capletIds = caplets.map((caplet) => caplet.manifest.id); + expect(capletIds).toStrictEqual(['com.other.caplet']); }); it('logs uninstallation progress', async () => { - const stateWithCaplet: CapletControllerState = { - caplets: { - 'com.example.test': { - manifest: validManifest, - subclusterId: 'subcluster-123', - installedAt: 1000, - }, + const mockAdapter = makeMockStorageAdapter(); + await seedAdapter(mockAdapter, { + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-123', + installedAt: 1000, }, - }; - const mockStorage = createMockStorage(stateWithCaplet); - const controller = CapletController.make(config, { - storage: mockStorage, + }); + const controller = await CapletController.make(config, { + adapter: mockAdapter, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, }); @@ -337,9 +298,9 @@ describe('CapletController.make', () => { describe('list', () => { it('returns empty array when no caplets installed', async () => { - const mockStorage = createMockStorage(emptyState); - const controller = CapletController.make(config, { - storage: mockStorage, + const mockAdapter = makeMockStorageAdapter(); + const controller = await CapletController.make(config, { + adapter: mockAdapter, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, }); @@ -355,23 +316,21 @@ describe('CapletController.make', () => { id: 'com.example.test2', name: 'Test Caplet 2', }; - const stateWithCaplets: CapletControllerState = { - caplets: { - 'com.example.test': { - manifest: validManifest, - subclusterId: 'subcluster-1', - installedAt: 1000, - }, - 'com.example.test2': { - manifest: manifest2, - subclusterId: 'subcluster-2', - installedAt: 2000, - }, + const mockAdapter = makeMockStorageAdapter(); + await seedAdapter(mockAdapter, { + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-1', + installedAt: 1000, }, - }; - const mockStorage = createMockStorage(stateWithCaplets); - const controller = CapletController.make(config, { - storage: mockStorage, + 'com.example.test2': { + manifest: manifest2, + subclusterId: 'subcluster-2', + installedAt: 2000, + }, + }); + const controller = await CapletController.make(config, { + adapter: mockAdapter, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, }); @@ -394,18 +353,16 @@ describe('CapletController.make', () => { describe('get', () => { it('returns caplet if exists', async () => { - const stateWithCaplet: CapletControllerState = { - caplets: { - 'com.example.test': { - manifest: validManifest, - subclusterId: 'subcluster-123', - installedAt: 1705320000000, - }, + const mockAdapter = makeMockStorageAdapter(); + await seedAdapter(mockAdapter, { + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-123', + installedAt: 1705320000000, }, - }; - const mockStorage = createMockStorage(stateWithCaplet); - const controller = CapletController.make(config, { - storage: mockStorage, + }); + const controller = await CapletController.make(config, { + adapter: mockAdapter, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, }); @@ -420,9 +377,9 @@ describe('CapletController.make', () => { }); it('returns undefined if caplet not found', async () => { - const mockStorage = createMockStorage(emptyState); - const controller = CapletController.make(config, { - storage: mockStorage, + const mockAdapter = makeMockStorageAdapter(); + const controller = await CapletController.make(config, { + adapter: mockAdapter, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, }); @@ -435,18 +392,16 @@ describe('CapletController.make', () => { describe('getByService', () => { it('returns caplet providing the service', async () => { - const stateWithCaplet: CapletControllerState = { - caplets: { - 'com.example.test': { - manifest: validManifest, - subclusterId: 'subcluster-123', - installedAt: 1000, - }, + const mockAdapter = makeMockStorageAdapter(); + await seedAdapter(mockAdapter, { + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-123', + installedAt: 1000, }, - }; - const mockStorage = createMockStorage(stateWithCaplet); - const controller = CapletController.make(config, { - storage: mockStorage, + }); + const controller = await CapletController.make(config, { + adapter: mockAdapter, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, }); @@ -458,18 +413,16 @@ describe('CapletController.make', () => { }); it('returns undefined if no caplet provides the service', async () => { - const stateWithCaplet: CapletControllerState = { - caplets: { - 'com.example.test': { - manifest: validManifest, - subclusterId: 'subcluster-123', - installedAt: 1000, - }, + const mockAdapter = makeMockStorageAdapter(); + await seedAdapter(mockAdapter, { + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-123', + installedAt: 1000, }, - }; - const mockStorage = createMockStorage(stateWithCaplet); - const controller = CapletController.make(config, { - storage: mockStorage, + }); + const controller = await CapletController.make(config, { + adapter: mockAdapter, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, }); @@ -486,23 +439,21 @@ describe('CapletController.make', () => { name: 'Test Caplet 2', providedServices: ['signer', 'verifier'], }; - const stateWithCaplets: CapletControllerState = { - caplets: { - 'com.example.test': { - manifest: validManifest, - subclusterId: 'subcluster-1', - installedAt: 1000, - }, - 'com.example.test2': { - manifest: manifest2, - subclusterId: 'subcluster-2', - installedAt: 2000, - }, + const mockAdapter = makeMockStorageAdapter(); + await seedAdapter(mockAdapter, { + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-1', + installedAt: 1000, }, - }; - const mockStorage = createMockStorage(stateWithCaplets); - const controller = CapletController.make(config, { - storage: mockStorage, + 'com.example.test2': { + manifest: manifest2, + subclusterId: 'subcluster-2', + installedAt: 2000, + }, + }); + const controller = await CapletController.make(config, { + adapter: mockAdapter, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, }); diff --git a/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts index 584694631..3f7a062d4 100644 --- a/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts +++ b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts @@ -12,7 +12,8 @@ import type { import { isCapletManifest } from './types.ts'; import { Controller } from '../base-controller.ts'; import type { ControllerConfig } from '../base-controller.ts'; -import type { ControllerStorage } from '../storage/controller-storage.ts'; +import { ControllerStorage } from '../storage/controller-storage.ts'; +import type { StorageAdapter } from '../storage/types.ts'; /** * Caplet controller persistent state. @@ -76,8 +77,8 @@ export type CapletControllerFacet = { * These are attenuated - only the methods needed are provided. */ export type CapletControllerDeps = { - /** State storage for caplet data */ - storage: ControllerStorage; + /** Storage adapter for creating controller storage */ + adapter: StorageAdapter; /** Launch a subcluster for a caplet */ launchSubcluster: (config: ClusterConfig) => Promise; /** Terminate a caplet's subcluster */ @@ -129,12 +130,20 @@ export class CapletController extends Controller< * @param deps - Controller dependencies (attenuated for POLA). * @returns A hardened CapletController exo. */ - static make( + static async make( config: ControllerConfig, deps: CapletControllerDeps, - ): CapletControllerFacet { + ): Promise { + // Create storage internally + const storage = await ControllerStorage.make({ + namespace: 'caplet', + adapter: deps.adapter, + defaultState: { caplets: {} }, + logger: config.logger.subLogger({ tags: ['storage'] }), + }); + const controller = new CapletController( - deps.storage, + storage, config.logger, deps.launchSubcluster, deps.terminateSubcluster, @@ -209,8 +218,7 @@ export class CapletController extends Controller< // Launch subcluster const { subclusterId } = await this.#launchSubcluster(clusterConfig); - // Store caplet data - await this.update((draft) => { + this.update((draft) => { draft.caplets[id] = { manifest, subclusterId, @@ -238,8 +246,7 @@ export class CapletController extends Controller< // Terminate the subcluster await this.#terminateSubcluster(caplet.subclusterId); - // Remove from storage - await this.update((draft) => { + this.update((draft) => { delete draft.caplets[capletId]; }); diff --git a/packages/omnium-gatherum/src/controllers/index.ts b/packages/omnium-gatherum/src/controllers/index.ts index cc6326308..120d56561 100644 --- a/packages/omnium-gatherum/src/controllers/index.ts +++ b/packages/omnium-gatherum/src/controllers/index.ts @@ -7,13 +7,11 @@ export { makeFacet } from './facet.ts'; export type { NamespacedStorage, StorageAdapter, - ControllerStorage, ControllerStorageConfig, } from './storage/index.ts'; export { makeChromeStorageAdapter, - makeNamespacedStorage, - makeControllerStorage, + ControllerStorage, } from './storage/index.ts'; // Caplet diff --git a/packages/omnium-gatherum/src/controllers/storage/controller-storage.test.ts b/packages/omnium-gatherum/src/controllers/storage/controller-storage.test.ts index c01093441..93ea2b5c2 100644 --- a/packages/omnium-gatherum/src/controllers/storage/controller-storage.test.ts +++ b/packages/omnium-gatherum/src/controllers/storage/controller-storage.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { makeControllerStorage } from './controller-storage.ts'; +import { ControllerStorage } from './controller-storage.ts'; import type { StorageAdapter } from './types.ts'; type TestState = { @@ -9,7 +9,7 @@ type TestState = { count: number; }; -describe('makeControllerStorage', () => { +describe('ControllerStorage', () => { const mockAdapter: StorageAdapter = { get: vi.fn(), set: vi.fn(), @@ -17,6 +17,14 @@ describe('makeControllerStorage', () => { keys: vi.fn(), }; + const mockLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + subLogger: vi.fn().mockReturnThis(), + }; + const defaultState: TestState = { installed: [], manifests: {}, @@ -24,6 +32,7 @@ describe('makeControllerStorage', () => { }; beforeEach(() => { + vi.clearAllMocks(); vi.mocked(mockAdapter.get).mockResolvedValue(undefined); vi.mocked(mockAdapter.set).mockResolvedValue(undefined); vi.mocked(mockAdapter.delete).mockResolvedValue(undefined); @@ -46,10 +55,12 @@ describe('makeControllerStorage', () => { return undefined; }); - const storage = await makeControllerStorage({ + const storage = await ControllerStorage.make({ namespace: 'test', adapter: mockAdapter, defaultState, + logger: mockLogger as never, + debounceMs: 0, }); expect(storage.state.installed).toStrictEqual(['app1']); @@ -67,7 +78,7 @@ describe('makeControllerStorage', () => { return undefined; }); - const storage = await makeControllerStorage({ + const storage = await ControllerStorage.make({ namespace: 'test', adapter: mockAdapter, defaultState: { @@ -75,6 +86,8 @@ describe('makeControllerStorage', () => { manifests: {}, metadata: { version: 1 }, }, + logger: mockLogger as never, + debounceMs: 0, }); expect(storage.state.installed).toStrictEqual(['existing']); @@ -85,10 +98,12 @@ describe('makeControllerStorage', () => { it('uses all defaults when storage is empty', async () => { vi.mocked(mockAdapter.keys).mockResolvedValue([]); - const storage = await makeControllerStorage({ + const storage = await ControllerStorage.make({ namespace: 'test', adapter: mockAdapter, defaultState, + logger: mockLogger as never, + debounceMs: 0, }); expect(storage.state.installed).toStrictEqual([]); @@ -97,10 +112,12 @@ describe('makeControllerStorage', () => { }); it('returns hardened state copy', async () => { - const storage = await makeControllerStorage({ + const storage = await ControllerStorage.make({ namespace: 'test', adapter: mockAdapter, defaultState: { items: ['original'] as string[] }, + logger: mockLogger as never, + debounceMs: 0, }); // Get a reference to the state @@ -125,10 +142,12 @@ describe('makeControllerStorage', () => { vi.mocked(mockAdapter.keys).mockResolvedValue(['ns.count']); vi.mocked(mockAdapter.get).mockResolvedValue(42); - const storage = await makeControllerStorage({ + const storage = await ControllerStorage.make({ namespace: 'ns', adapter: mockAdapter, defaultState: { count: 0 }, + logger: mockLogger as never, + debounceMs: 0, }); expect(storage.state.count).toBe(42); @@ -137,99 +156,135 @@ describe('makeControllerStorage', () => { describe('update', () => { it('persists only modified top-level keys', async () => { - const storage = await makeControllerStorage({ + const storage = await ControllerStorage.make({ namespace: 'test', adapter: mockAdapter, defaultState, + logger: mockLogger as never, + debounceMs: 0, }); - await storage.update((draft) => { + storage.update((draft) => { draft.installed.push('new-app'); // manifests and count not modified }); + // Wait for persistence (debounced but set to 0ms) + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(mockAdapter.set).toHaveBeenCalledTimes(1); expect(mockAdapter.set).toHaveBeenCalledWith('test.installed', [ 'new-app', ]); }); - it('updates in-memory state after persistence', async () => { - const storage = await makeControllerStorage({ + it('updates in-memory state immediately', async () => { + const storage = await ControllerStorage.make({ namespace: 'test', adapter: mockAdapter, defaultState, + logger: mockLogger as never, + debounceMs: 0, }); - await storage.update((draft) => { + storage.update((draft) => { draft.installed.push('item1'); }); + // State updated synchronously expect(storage.state.installed).toStrictEqual(['item1']); }); it('does not persist when no changes made', async () => { - const storage = await makeControllerStorage({ + // Clear any pending operations from previous tests + await new Promise((resolve) => setTimeout(resolve, 15)); + vi.clearAllMocks(); + + const storage = await ControllerStorage.make({ namespace: 'test', adapter: mockAdapter, defaultState, + logger: mockLogger as never, + debounceMs: 0, }); - await storage.update((draft) => { + storage.update((draft) => { // No actual changes draft.count = 0; }); + // Wait for potential persistence + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(mockAdapter.set).not.toHaveBeenCalled(); }); it('persists multiple modified keys', async () => { - const storage = await makeControllerStorage({ + const storage = await ControllerStorage.make({ namespace: 'test', adapter: mockAdapter, defaultState: { a: 1, b: 2, c: 3 }, + logger: mockLogger as never, + debounceMs: 0, }); - await storage.update((draft) => { + storage.update((draft) => { draft.a = 10; draft.c = 30; }); + // Wait for persistence + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(mockAdapter.set).toHaveBeenCalledTimes(2); expect(mockAdapter.set).toHaveBeenCalledWith('test.a', 10); expect(mockAdapter.set).toHaveBeenCalledWith('test.c', 30); }); - it('does not update state if persistence fails', async () => { + it('updates state even if persistence fails (fire-and-forget)', async () => { vi.mocked(mockAdapter.set).mockRejectedValue(new Error('Storage error')); - const storage = await makeControllerStorage({ + const storage = await ControllerStorage.make({ namespace: 'test', adapter: mockAdapter, defaultState, + logger: mockLogger as never, + debounceMs: 0, }); - await expect( - storage.update((draft) => { - draft.count = 100; - }), - ).rejects.toThrow('Storage error'); + storage.update((draft) => { + draft.count = 100; + }); - // State should remain unchanged - expect(storage.state.count).toBe(0); + // State updated immediately despite persistence failure + expect(storage.state.count).toBe(100); + + // Wait for persistence attempt + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Error should be logged + expect(mockLogger.error).toHaveBeenCalledWith( + 'Failed to persist state changes:', + expect.any(Error), + ); }); it('handles nested object modifications', async () => { - const storage = await makeControllerStorage({ + const storage = await ControllerStorage.make({ namespace: 'test', adapter: mockAdapter, defaultState, + logger: mockLogger as never, + debounceMs: 0, }); - await storage.update((draft) => { + storage.update((draft) => { draft.manifests['new-app'] = { name: 'New App' }; }); + // Wait for persistence + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(mockAdapter.set).toHaveBeenCalledWith('test.manifests', { 'new-app': { name: 'New App' }, }); @@ -239,16 +294,21 @@ describe('makeControllerStorage', () => { vi.mocked(mockAdapter.keys).mockResolvedValue(['test.installed']); vi.mocked(mockAdapter.get).mockResolvedValue(['app1', 'app2']); - const storage = await makeControllerStorage({ + const storage = await ControllerStorage.make({ namespace: 'test', adapter: mockAdapter, defaultState, + logger: mockLogger as never, + debounceMs: 0, }); - await storage.update((draft) => { + storage.update((draft) => { draft.installed = draft.installed.filter((id) => id !== 'app1'); }); + // Wait for persistence + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(mockAdapter.set).toHaveBeenCalledWith('test.installed', ['app2']); }); @@ -259,78 +319,195 @@ describe('makeControllerStorage', () => { app2: { name: 'App 2' }, }); - const storage = await makeControllerStorage({ + const storage = await ControllerStorage.make({ namespace: 'test', adapter: mockAdapter, defaultState, + logger: mockLogger as never, + debounceMs: 0, }); - await storage.update((draft) => { + storage.update((draft) => { delete draft.manifests.app1; }); + // Wait for persistence + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(mockAdapter.set).toHaveBeenCalledWith('test.manifests', { app2: { name: 'App 2' }, }); }); }); - describe('reload', () => { - it('reloads state from storage', async () => { - vi.mocked(mockAdapter.keys).mockResolvedValue([]); + describe('namespace isolation', () => { + it('uses different prefixes for different namespaces', async () => { + await ControllerStorage.make({ + namespace: 'caplet', + adapter: mockAdapter, + defaultState: { value: 1 }, + logger: mockLogger as never, + debounceMs: 0, + }); - const storage = await makeControllerStorage({ + await ControllerStorage.make({ + namespace: 'service', + adapter: mockAdapter, + defaultState: { value: 2 }, + logger: mockLogger as never, + debounceMs: 0, + }); + + expect(mockAdapter.keys).toHaveBeenCalledWith('caplet.'); + expect(mockAdapter.keys).toHaveBeenCalledWith('service.'); + }); + }); + + describe('debouncing with key accumulation', () => { + it('accumulates modified keys across multiple updates', async () => { + vi.useFakeTimers(); + const storage = await ControllerStorage.make({ namespace: 'test', adapter: mockAdapter, - defaultState, + defaultState: { a: 0, b: 0, c: 0 }, + logger: mockLogger as never, + debounceMs: 100, }); - expect(storage.state.count).toBe(0); + // First update: modifies a and b + storage.update((draft) => { + draft.a = 1; + draft.b = 1; + }); + + // Second update at t=50ms: modifies only a + vi.advanceTimersByTime(50); + storage.update((draft) => { + draft.a = 2; + }); - // Simulate external storage update - vi.mocked(mockAdapter.keys).mockResolvedValue(['test.count']); - vi.mocked(mockAdapter.get).mockResolvedValue(999); + // Timer should fire at t=100ms (from first update) + vi.advanceTimersByTime(50); + await vi.runAllTimersAsync(); - await storage.reload(); + // Both a and b should be persisted (accumulated keys) + expect(mockAdapter.set).toHaveBeenCalledWith('test.a', 2); + expect(mockAdapter.set).toHaveBeenCalledWith('test.b', 1); - expect(storage.state.count).toBe(999); + vi.useRealTimers(); }); - it('merges with defaults after reload', async () => { - vi.mocked(mockAdapter.keys).mockResolvedValue(['test.count']); - vi.mocked(mockAdapter.get).mockResolvedValue(42); + it('does not reset timer on subsequent writes', async () => { + vi.useFakeTimers(); + const storage = await ControllerStorage.make({ + namespace: 'test', + adapter: mockAdapter, + defaultState: { a: 0 }, + logger: mockLogger as never, + debounceMs: 100, + }); - const storage = await makeControllerStorage({ + storage.update((draft) => { + draft.a = 1; + }); + + // Second write at t=90ms (before first timer fires) + vi.advanceTimersByTime(90); + storage.update((draft) => { + draft.a = 2; + }); + + // Timer fires at t=100ms (NOT reset to t=190ms) + vi.advanceTimersByTime(10); + await vi.runAllTimersAsync(); + + expect(mockAdapter.set).toHaveBeenCalledWith('test.a', 2); + + vi.useRealTimers(); + }); + + it('writes immediately when idle > debounceMs', async () => { + vi.useFakeTimers(); + const storage = await ControllerStorage.make({ namespace: 'test', adapter: mockAdapter, - defaultState, + defaultState: { a: 0 }, + logger: mockLogger as never, + debounceMs: 100, + }); + + storage.update((draft) => { + draft.a = 1; }); + await vi.runAllTimersAsync(); + vi.clearAllMocks(); - // Reload - count from storage, others from defaults - await storage.reload(); + // Wait 150ms (> debounceMs) + vi.advanceTimersByTime(150); - expect(storage.state.count).toBe(42); - expect(storage.state.installed).toStrictEqual([]); - expect(storage.state.manifests).toStrictEqual({}); + // Next write should be immediate (no debounce) + storage.update((draft) => { + draft.a = 2; + }); + await vi.runAllTimersAsync(); + + expect(mockAdapter.set).toHaveBeenCalledWith('test.a', 2); + + vi.useRealTimers(); }); }); - describe('namespace isolation', () => { - it('uses different prefixes for different namespaces', async () => { - await makeControllerStorage({ - namespace: 'caplet', + describe('clear', () => { + it('resets state to default', async () => { + const testDefaultState = { items: [] as string[], count: 0 }; + const storage = await ControllerStorage.make({ + namespace: 'test', adapter: mockAdapter, - defaultState: { value: 1 }, + defaultState: testDefaultState, + logger: mockLogger as never, + debounceMs: 0, }); - await makeControllerStorage({ - namespace: 'service', + // Modify state + storage.update((draft) => { + draft.items.push('item1'); + draft.count = 1; + }); + + expect(storage.state.items).toStrictEqual(['item1']); + expect(storage.state.count).toBe(1); + + // Clear + storage.clear(); + + expect(storage.state.items).toStrictEqual([]); + expect(storage.state.count).toBe(0); + }); + + it('persists cleared state', async () => { + const clearDefaultState = { a: 0, b: 0 }; + const storage = await ControllerStorage.make({ + namespace: 'test', adapter: mockAdapter, - defaultState: { value: 2 }, + defaultState: clearDefaultState, + logger: mockLogger as never, + debounceMs: 0, }); - expect(mockAdapter.keys).toHaveBeenCalledWith('caplet.'); - expect(mockAdapter.keys).toHaveBeenCalledWith('service.'); + storage.update((draft) => { + draft.a = 5; + draft.b = 10; + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + vi.clearAllMocks(); + + storage.clear(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockAdapter.set).toHaveBeenCalledWith('test.a', 0); + expect(mockAdapter.set).toHaveBeenCalledWith('test.b', 0); }); }); }); diff --git a/packages/omnium-gatherum/src/controllers/storage/controller-storage.ts b/packages/omnium-gatherum/src/controllers/storage/controller-storage.ts index 22ef617f7..a2c1939e9 100644 --- a/packages/omnium-gatherum/src/controllers/storage/controller-storage.ts +++ b/packages/omnium-gatherum/src/controllers/storage/controller-storage.ts @@ -1,3 +1,4 @@ +import type { Logger } from '@metamask/logger'; import type { Json } from '@metamask/utils'; import { enablePatches, produce } from 'immer'; import type { Patch } from 'immer'; @@ -21,108 +22,121 @@ export type ControllerStorageConfig> = { adapter: StorageAdapter; /** Default state values - used for initialization and type inference */ defaultState: State; + /** Logger for storage operations */ + logger: Logger; + /** Debounce delay in milliseconds (default: 100, set to 0 for tests) */ + debounceMs?: number; }; +/** + * Internal options passed to constructor after async initialization. + */ +type ControllerStorageOptions> = + ControllerStorageConfig & { + /** Initial state loaded from storage */ + initialState: State; + }; + /** * ControllerStorage provides a simplified state management interface for controllers. * * Features: * - Flat top-level key mapping: `state.foo` maps to `{namespace}.foo` in storage * - Immer-based updates with automatic change detection + * - Synchronous state updates with debounced persistence * - Only modified top-level keys are persisted + * - Fire-and-forget persistence (errors logged but don't rollback state) * - Eager loading on initialization * * @template State - The state object type (must have Json-serializable values) */ -export type ControllerStorage> = { +export class ControllerStorage> { + readonly #adapter: StorageAdapter; + + readonly #prefix: string; + + readonly #defaultState: State; + + readonly #logger: Logger; + + readonly #debounceMs: number; + + #state: State; + + #pendingPersist: ReturnType | null = null; + + readonly #pendingKeys: Set = new Set(); + + #lastWriteTime: number = 0; + /** - * Current state (readonly, hardened). - * Access individual properties: `storage.state.installed` + * Private constructor - use static make() factory method. + * + * @param options - Configuration including initial loaded state. */ - readonly state: Readonly; + // eslint-disable-next-line no-restricted-syntax -- TypeScript doesn't support # for constructors + private constructor(options: ControllerStorageOptions) { + this.#adapter = options.adapter; + this.#prefix = `${options.namespace}.`; + this.#defaultState = options.defaultState; + this.#logger = options.logger; + this.#debounceMs = options.debounceMs ?? 100; + this.#state = options.initialState; + } /** - * Update state using an immer producer function. - * Only modified top-level keys will be persisted to storage. + * Create a ControllerStorage instance for a controller. * - * @param producer - Function that mutates a draft of the state - * @returns Promise that resolves when changes are persisted - * @throws If storage persistence fails (state remains unchanged) + * This factory function: + * 1. Loads existing state from storage for the namespace + * 2. Merges with defaults (storage values take precedence) + * 3. Returns a hardened ControllerStorage instance + * + * @param config - Configuration including namespace, adapter, and default state. + * @returns Promise resolving to a hardened ControllerStorage instance. * * @example * ```typescript - * await storage.update(draft => { - * draft.installed.push('com.example.app'); - * draft.manifests['com.example.app'] = manifest; + * const capletState = await ControllerStorage.make({ + * namespace: 'caplet', + * adapter: storageAdapter, + * defaultState: { installed: [], manifests: {} }, + * logger: logger.subLogger({ tags: ['storage'] }), * }); - * ``` - */ - update: (producer: (draft: State) => void) => Promise; - - /** - * Force reload state from storage. - * Useful for syncing after external storage changes. - */ - reload: () => Promise; -}; - -/** - * Create a ControllerStorage instance for a controller. - * - * This factory function: - * 1. Loads existing state from storage for the namespace - * 2. Merges with defaults (storage values take precedence) - * 3. Returns a hardened ControllerStorage interface - * - * @param config - Configuration including namespace, adapter, and default state. - * @returns Promise resolving to a hardened ControllerStorage instance. - * - * @example - * ```typescript - * const capletState = await makeControllerStorage({ - * namespace: 'caplet', - * adapter: storageAdapter, - * defaultState: { installed: [], manifests: {} } - * }); - * - * // Read state - * console.log(capletState.state.installed); - * - * // Update state - * await capletState.update(draft => { - * draft.installed.push('com.example.app'); - * }); - * ``` - */ -export async function makeControllerStorage>( - config: ControllerStorageConfig, -): Promise> { - const { namespace, adapter, defaultState } = config; - const prefix = `${namespace}.`; - - /** - * Build a storage key from a state property name. * - * @param stateKey - The state property name. - * @returns The namespaced storage key. - */ - const buildKey = (stateKey: string): string => `${prefix}${stateKey}`; - - /** - * Strip namespace prefix from a storage key. + * // Read state + * console.log(capletState.state.installed); * - * @param fullKey - The full namespaced storage key. - * @returns The state property name without prefix. + * // Update state (synchronous) + * capletState.update(draft => { + * draft.installed.push('com.example.app'); + * }); + * ``` */ - const stripPrefix = (fullKey: string): string => fullKey.slice(prefix.length); + static async make>( + config: ControllerStorageConfig, + ): Promise> { + const initialState = await this.#loadState(config); + return harden( + new ControllerStorage({ + ...config, + initialState, + }), + ); + } /** * Load all state from storage, merging with defaults. * Storage values take precedence over defaults. * + * @param config - Configuration with adapter, namespace, and defaults. * @returns The merged state object. */ - const loadState = async (): Promise => { + static async #loadState>( + config: ControllerStorageConfig, + ): Promise { + const { namespace, adapter, defaultState } = config; + const prefix = `${namespace}.`; const allKeys = await adapter.keys(prefix); // Start with a copy of defaults @@ -131,7 +145,7 @@ export async function makeControllerStorage>( // Load and merge values from storage await Promise.all( allKeys.map(async (fullKey) => { - const key = stripPrefix(fullKey) as keyof State; + const key = fullKey.slice(prefix.length) as keyof State; const value = await adapter.get(fullKey); if (value !== undefined) { state[key] = value as State[keyof State]; @@ -142,26 +156,136 @@ export async function makeControllerStorage>( return produce({}, (draft) => { Object.assign(draft, state); }) as State; - }; + } + + /** + * Current state (readonly, deeply frozen by immer). + * Access individual properties: `storage.state.installed` + * + * @returns The current readonly state. + */ + get state(): Readonly { + return this.#state; + } + + /** + * Update state using an immer producer function. + * State is updated synchronously in memory. + * Persistence is queued and debounced (fire-and-forget). + * + * @param producer - Function that mutates a draft of the state or returns new state + * + * @example + * ```typescript + * // Mutate draft + * storage.update(draft => { + * draft.installed.push('com.example.app'); + * draft.manifests['com.example.app'] = manifest; + * }); + */ + update(producer: (draft: State) => void | State): void { + // Capture state before operations to avoid race conditions + const stateSnapshot = this.#state; + + // Use immer's produce with patches callback to track changes + let patches: Patch[] = []; + const nextState = produce(stateSnapshot, producer, (patchList) => { + patches = patchList; + }); + + // No changes - nothing to do + if (patches.length === 0) { + return; + } + + // Update in-memory state immediately + this.#state = nextState; + + // Queue debounced persistence (fire-and-forget) + this.#schedulePersist(patches); + } + + /** + * Clear all state and reset to default values. + * Updates state synchronously, persistence is debounced. + */ + clear(): void { + this.update((draft) => { + Object.assign(draft, this.#defaultState); + }); + } /** - * Persist specific keys to storage. + * Schedule debounced persistence with key accumulation. + * Implements bounded latency (timer not reset) and immediate writes after idle. * - * @param stateToSave - The state object containing values to persist. + * @param patches - Immer patches describing changes. + */ + #schedulePersist(patches: Patch[]): void { + const now = Date.now(); + const timeSinceLastWrite = now - this.#lastWriteTime; + this.#lastWriteTime = now; + + const modifiedKeys = this.#getModifiedKeys(patches); + for (const key of modifiedKeys) { + this.#pendingKeys.add(key); + } + + if ( + timeSinceLastWrite > this.#debounceMs && + this.#pendingPersist === null + ) { + this.#flushPendingWrites(); + return; + } + + if (this.#pendingPersist === null) { + this.#pendingPersist = setTimeout(() => { + this.#flushPendingWrites(); + }, this.#debounceMs); + } + // else: timer already running, just accumulate keys, don't reset + } + + /** + * Flush pending writes to storage. + * Captures accumulated keys and persists current state values. + */ + #flushPendingWrites(): void { + if (this.#pendingKeys.size === 0) { + this.#pendingPersist = null; + return; + } + + const keysToWrite = new Set(this.#pendingKeys); + this.#pendingKeys.clear(); + this.#pendingPersist = null; + + // Persist current state values for accumulated keys + this.#persistAccumulatedKeys(this.#state, keysToWrite).catch((error) => { + this.#logger.error('Failed to persist state changes:', error); + }); + } + + /** + * Persist accumulated keys to storage. + * Always persists current state values (last-write-wins). + * + * @param state - The current state to persist from. * @param keys - Set of top-level keys to persist. */ - const persistKeys = async ( - stateToSave: State, + async #persistAccumulatedKeys( + state: State, keys: Set, - ): Promise => { + ): Promise { await Promise.all( Array.from(keys).map(async (key) => { - const storageKey = buildKey(key); - const value = stateToSave[key as keyof State]; - await adapter.set(storageKey, value as Json); + const storageKey = this.#buildKey(key); + const value = state[key as keyof State]; + await this.#adapter.set(storageKey, value as Json); }), ); - }; + } /** * Extract top-level keys that were modified from immer patches. @@ -169,7 +293,7 @@ export async function makeControllerStorage>( * @param patches - Array of immer patches describing changes. * @returns Set of modified top-level keys. */ - const getModifiedKeys = (patches: Patch[]): Set => { + #getModifiedKeys(patches: Patch[]): Set { const keys = new Set(); for (const patch of patches) { // The first element of path is always the top-level key @@ -178,47 +302,16 @@ export async function makeControllerStorage>( } } return keys; - }; - - // Load initial state - let currentState = await loadState(); - - const storage: ControllerStorage = { - get state(): Readonly { - return currentState; - }, - - async update(producer: (draft: State) => void): Promise { - // Capture state before async operations to avoid race conditions - const stateSnapshot = currentState; - - // Use immer's produce with patches callback to track changes - let patches: Patch[] = []; - const nextState = produce(stateSnapshot, producer, (patchList) => { - patches = patchList; - }); + } - // No changes - nothing to do - if (patches.length === 0) { - return; - } - - // Determine which top-level keys changed - const modifiedKeys = getModifiedKeys(patches); - - // Persist only the modified keys - await persistKeys(nextState, modifiedKeys); - - // Update in-memory state only after successful persistence - // eslint-disable-next-line require-atomic-updates -- Last-write-wins is intentional - currentState = nextState; - }, - - async reload(): Promise { - currentState = await loadState(); - }, - }; - - return harden(storage); + /** + * Build a storage key from a state property name. + * + * @param stateKey - The state property name. + * @returns The namespaced storage key. + */ + #buildKey(stateKey: string): string { + return `${this.#prefix}${stateKey}`; + } } -harden(makeControllerStorage); +harden(ControllerStorage); diff --git a/packages/omnium-gatherum/src/controllers/storage/index.ts b/packages/omnium-gatherum/src/controllers/storage/index.ts index 9a88b8acf..8f0382e45 100644 --- a/packages/omnium-gatherum/src/controllers/storage/index.ts +++ b/packages/omnium-gatherum/src/controllers/storage/index.ts @@ -1,8 +1,4 @@ export type { NamespacedStorage, StorageAdapter } from './types.ts'; -export type { - ControllerStorage, - ControllerStorageConfig, -} from './controller-storage.ts'; +export type { ControllerStorageConfig } from './controller-storage.ts'; export { makeChromeStorageAdapter } from './chrome-storage.ts'; -export { makeNamespacedStorage } from './namespaced-storage.ts'; -export { makeControllerStorage } from './controller-storage.ts'; +export { ControllerStorage } from './controller-storage.ts'; diff --git a/packages/omnium-gatherum/src/controllers/storage/namespaced-storage.test.ts b/packages/omnium-gatherum/src/controllers/storage/namespaced-storage.test.ts deleted file mode 100644 index b427b63fe..000000000 --- a/packages/omnium-gatherum/src/controllers/storage/namespaced-storage.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -import { makeNamespacedStorage } from './namespaced-storage.ts'; -import type { StorageAdapter } from './types.ts'; - -describe('makeNamespacedStorage', () => { - const mockAdapter: StorageAdapter = { - get: vi.fn(), - set: vi.fn(), - delete: vi.fn(), - keys: vi.fn(), - }; - - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(mockAdapter.get).mockResolvedValue(undefined); - vi.mocked(mockAdapter.set).mockResolvedValue(undefined); - vi.mocked(mockAdapter.delete).mockResolvedValue(undefined); - vi.mocked(mockAdapter.keys).mockResolvedValue([]); - }); - - describe('get', () => { - it('prefixes key with namespace', async () => { - vi.mocked(mockAdapter.get).mockResolvedValue('value'); - - const storage = makeNamespacedStorage('caplet', mockAdapter); - const result = await storage.get('myKey'); - - expect(result).toBe('value'); - expect(mockAdapter.get).toHaveBeenCalledWith('caplet.myKey'); - }); - - it('returns undefined for non-existent key', async () => { - vi.mocked(mockAdapter.get).mockResolvedValue(undefined); - - const storage = makeNamespacedStorage('caplet', mockAdapter); - const result = await storage.get('nonExistent'); - - expect(result).toBeUndefined(); - }); - }); - - describe('set', () => { - it('prefixes key with namespace', async () => { - const storage = makeNamespacedStorage('caplet', mockAdapter); - await storage.set('myKey', 'myValue'); - - expect(mockAdapter.set).toHaveBeenCalledWith('caplet.myKey', 'myValue'); - }); - - it('handles complex values', async () => { - const complexValue = { nested: { data: [1, 2, 3] } }; - - const storage = makeNamespacedStorage('caplet', mockAdapter); - await storage.set('complex', complexValue); - - expect(mockAdapter.set).toHaveBeenCalledWith( - 'caplet.complex', - complexValue, - ); - }); - }); - - describe('delete', () => { - it('prefixes key with namespace', async () => { - const storage = makeNamespacedStorage('caplet', mockAdapter); - await storage.delete('myKey'); - - expect(mockAdapter.delete).toHaveBeenCalledWith('caplet.myKey'); - }); - }); - - describe('has', () => { - it('returns true when key exists', async () => { - vi.mocked(mockAdapter.get).mockResolvedValue('value'); - - const storage = makeNamespacedStorage('caplet', mockAdapter); - const result = await storage.has('myKey'); - - expect(result).toBe(true); - expect(mockAdapter.get).toHaveBeenCalledWith('caplet.myKey'); - }); - - it('returns false when key does not exist', async () => { - vi.mocked(mockAdapter.get).mockResolvedValue(undefined); - - const storage = makeNamespacedStorage('caplet', mockAdapter); - const result = await storage.has('nonExistent'); - - expect(result).toBe(false); - }); - }); - - describe('keys', () => { - it('returns keys with namespace prefix stripped', async () => { - vi.mocked(mockAdapter.keys).mockResolvedValue([ - 'caplet.key1', - 'caplet.key2', - 'caplet.nested.key', - ]); - - const storage = makeNamespacedStorage('caplet', mockAdapter); - const result = await storage.keys(); - - expect(result).toStrictEqual(['key1', 'key2', 'nested.key']); - expect(mockAdapter.keys).toHaveBeenCalledWith('caplet.'); - }); - - it('returns empty array when no keys in namespace', async () => { - vi.mocked(mockAdapter.keys).mockResolvedValue([]); - - const storage = makeNamespacedStorage('caplet', mockAdapter); - const result = await storage.keys(); - - expect(result).toStrictEqual([]); - }); - }); - - describe('clear', () => { - it('deletes all keys in namespace', async () => { - vi.mocked(mockAdapter.keys).mockResolvedValue([ - 'caplet.key1', - 'caplet.key2', - ]); - - const storage = makeNamespacedStorage('caplet', mockAdapter); - await storage.clear(); - - expect(mockAdapter.delete).toHaveBeenCalledTimes(2); - expect(mockAdapter.delete).toHaveBeenCalledWith('caplet.key1'); - expect(mockAdapter.delete).toHaveBeenCalledWith('caplet.key2'); - }); - - it('does nothing when namespace is empty', async () => { - vi.mocked(mockAdapter.keys).mockResolvedValue([]); - - const storage = makeNamespacedStorage('caplet', mockAdapter); - await storage.clear(); - - expect(mockAdapter.delete).not.toHaveBeenCalled(); - }); - }); - - describe('namespace isolation', () => { - it('uses different prefixes for different namespaces', async () => { - const storage1 = makeNamespacedStorage('caplet', mockAdapter); - const storage2 = makeNamespacedStorage('service', mockAdapter); - - await storage1.set('key', 'value1'); - await storage2.set('key', 'value2'); - - expect(mockAdapter.set).toHaveBeenCalledWith('caplet.key', 'value1'); - expect(mockAdapter.set).toHaveBeenCalledWith('service.key', 'value2'); - }); - }); -}); diff --git a/packages/omnium-gatherum/src/controllers/storage/namespaced-storage.ts b/packages/omnium-gatherum/src/controllers/storage/namespaced-storage.ts deleted file mode 100644 index 51e0c3eae..000000000 --- a/packages/omnium-gatherum/src/controllers/storage/namespaced-storage.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { Json } from '@metamask/utils'; - -import type { NamespacedStorage, StorageAdapter } from './types.ts'; - -/** - * Create a namespaced storage interface. - * All operations are scoped to the given namespace prefix. - * - * @param namespace - The namespace prefix for all keys. - * @param adapter - The underlying storage adapter. - * @returns A hardened NamespacedStorage instance. - */ -export function makeNamespacedStorage( - namespace: string, - adapter: StorageAdapter, -): NamespacedStorage { - const prefix = `${namespace}.`; - - const buildKey = (key: string): string => `${prefix}${key}`; - - const stripPrefix = (fullKey: string): string => fullKey.slice(prefix.length); - - return harden({ - async get(key: string): Promise { - return adapter.get(buildKey(key)); - }, - - async set(key: string, value: Json): Promise { - await adapter.set(buildKey(key), value); - }, - - async delete(key: string): Promise { - await adapter.delete(buildKey(key)); - }, - - async has(key: string): Promise { - const value = await adapter.get(buildKey(key)); - return value !== undefined; - }, - - async keys(): Promise { - const allKeys = await adapter.keys(prefix); - return allKeys.map(stripPrefix); - }, - - async clear(): Promise { - const allKeys = await this.keys(); - await Promise.all(allKeys.map(async (key) => this.delete(key))); - }, - }); -} -harden(makeNamespacedStorage); diff --git a/packages/omnium-gatherum/test/e2e/smoke.test.ts b/packages/omnium-gatherum/test/e2e/smoke.test.ts index 96640f725..f2ec0f92d 100644 --- a/packages/omnium-gatherum/test/e2e/smoke.test.ts +++ b/packages/omnium-gatherum/test/e2e/smoke.test.ts @@ -1,7 +1,7 @@ import { test, expect } from '@playwright/test'; import type { Page, BrowserContext } from '@playwright/test'; -import { loadExtension } from '../helpers.ts'; +import { loadExtension } from './utils.ts'; test.describe.configure({ mode: 'serial' }); diff --git a/packages/omnium-gatherum/test/helpers.ts b/packages/omnium-gatherum/test/e2e/utils.ts similarity index 96% rename from packages/omnium-gatherum/test/helpers.ts rename to packages/omnium-gatherum/test/e2e/utils.ts index a8306e37a..1caa88d6d 100644 --- a/packages/omnium-gatherum/test/helpers.ts +++ b/packages/omnium-gatherum/test/e2e/utils.ts @@ -6,7 +6,7 @@ export { sessionPath } from '@ocap/repo-tools/test-utils/extension'; const extensionPath = path.resolve( path.dirname(fileURLToPath(import.meta.url)), - '../dist', + '../../dist', ); export const loadExtension = async (contextId?: string) => { diff --git a/packages/omnium-gatherum/test/utils.ts b/packages/omnium-gatherum/test/utils.ts new file mode 100644 index 000000000..c6294a8ca --- /dev/null +++ b/packages/omnium-gatherum/test/utils.ts @@ -0,0 +1,31 @@ +import type { Json } from '@metamask/utils'; + +import type { StorageAdapter } from '../src/controllers/storage/types.ts'; + +/** + * Create a mock StorageAdapter for testing. + * + * @returns A mock storage adapter backed by an in-memory Map. + */ +export function makeMockStorageAdapter(): StorageAdapter { + const store = new Map(); + + return { + async get(key: string): Promise { + return store.get(key) as Value | undefined; + }, + async set(key: string, value: Json): Promise { + store.set(key, value); + }, + async delete(key: string): Promise { + store.delete(key); + }, + async keys(prefix?: string): Promise { + const allKeys = Array.from(store.keys()); + if (prefix === undefined) { + return allKeys; + } + return allKeys.filter((k) => k.startsWith(prefix)); + }, + }; +}