@ateam-ai-mobile/plugin-runtime 0.1.5 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@
6
6
  * the BundleStorage adapter (in-memory by default; @ateam-ai-mobile/expo provides disk).
7
7
  *
8
8
  * Usage:
9
- * const component = await bundleManager.load(pluginId, bundleUrl, version, hash);
9
+ * const component = await bundleManager.load(pluginId, bundleUrl, version, hash, grants);
10
10
  * // component = { id, type, Component, ... } (RegisteredUIPlugin)
11
11
  */
12
12
  import React from 'react';
@@ -15,7 +15,7 @@ import {
15
15
  useContext, useReducer, useLayoutEffect, useImperativeHandle, useDebugValue,
16
16
  } from 'react';
17
17
  import * as ReactNative from 'react-native';
18
- import { apiFetchRaw } from '@ateam-ai-mobile/core';
18
+ import { apiFetchRaw, getTenant } from '@ateam-ai-mobile/core';
19
19
  import * as PluginSDKModule from '../sdk';
20
20
  import SurfaceDrawer from '../surfaces/SurfaceDrawer';
21
21
  import SurfaceCard from '../surfaces/SurfaceCard';
@@ -24,7 +24,10 @@ import { useSurfaceStore } from '../registry/store';
24
24
  import SurfaceDispatcher from '../registry/SurfaceDispatcher';
25
25
  import { resolveBundleModule } from '../host';
26
26
  import { sha256Hex } from '../utils/sha256';
27
- import { getBundleStorage, type BundleCacheEntry } from './storage';
27
+ import { assertBundleIntegrity } from './integrity';
28
+ import { getBundleStorage, type BundleCacheEntry, type TrustedToolDeclaration } from './storage';
29
+ import { registerDeviceTools, unregisterDeviceTools, listDeviceTools } from '../tools/registry';
30
+ import type { PluginGrants } from '../pluginRuntime';
28
31
  import type { RegisteredUIPlugin } from '../sdk/types';
29
32
 
30
33
  // ── Require shim ─────────────────────────────────────────────
@@ -88,15 +91,147 @@ const requireShim = (name: string) => {
88
91
 
89
92
  const CACHE_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days
90
93
 
94
+ // What findPluginExport looks for: a renderable plugin, or one with a `tools` section.
95
+ const hasComponent = (val: any): boolean =>
96
+ !!val && typeof val === 'object' && typeof val.Component === 'function';
97
+ const hasTools = (val: any): boolean =>
98
+ !!val && typeof val === 'object' && Array.isArray(val.tools) && val.tools.length > 0;
99
+
91
100
  // ── BundleManager singleton ──────────────────────────────────
92
101
 
102
+ /** Where the bytes currently running came from. */
103
+ export type LoadedBundleSource = 'download' | 'disk-cache' | 'memory';
104
+
105
+ /**
106
+ * Provenance of one loaded bundle — what is RUNNING, not what was requested.
107
+ *
108
+ * `hash` is the sha256 of the evaluated bytes. `expected` is the hash the
109
+ * server's manifest advertised for that url. Equal means the phone is running
110
+ * the build the server currently describes; different means it is not, and the
111
+ * pair says so without needing a rebuild, a reinstall or a guess.
112
+ */
113
+ export interface LoadedBundleInfo {
114
+ bundleUrl: string;
115
+ version: string;
116
+ /** sha256 (hex, lowercase) of the bytes that were evaluated. */
117
+ hash: string | null;
118
+ /** sha256 the server advertised for this url at load time, if any. */
119
+ expected: string | null;
120
+ source: LoadedBundleSource;
121
+ tenant: string | null;
122
+ loadedAt: number;
123
+ }
124
+
93
125
  class BundleManager {
94
- /** In-memory module cache: bundleUrl → evaluated module.exports */
126
+ /** In-memory module cache: bundleUrl → evaluated module.exports. Holds ONE
127
+ * tenant's modules at a time — the tenant boundary is `cacheTenant` /
128
+ * `requireTenant()`, NOT the key. */
95
129
  private modules = new Map<string, any>();
130
+ /** What is actually RUNNING, keyed by bundleUrl (for the current tenant). See
131
+ * getLoadedBundles(). */
132
+ private provenance = new Map<string, LoadedBundleInfo>();
96
133
 
97
134
  /** In-flight download deduplication: bundleUrl → Promise */
98
135
  private inflight = new Map<string, Promise<any>>();
99
136
 
137
+ /**
138
+ * The tenant the in-memory caches (modules / provenance / inflight) currently
139
+ * belong to. This is the tenant BOUNDARY for evaluated code.
140
+ *
141
+ * A bundleUrl resolves PER-TENANT on the server — one url, different bytes for
142
+ * different tenants — so a memory hit keyed on url alone would hand tenant B
143
+ * the module tenant A already evaluated and ran. PR #9 scoped the DISK cache
144
+ * (per-tenant index + hash gate); the in-memory map was left url-keyed. This
145
+ * closes that: the caches only ever hold ONE tenant's modules, and a switch
146
+ * drops them before anything is served.
147
+ */
148
+ private cacheTenant: string | null = null;
149
+
150
+ /**
151
+ * pluginIds this manager registered device tools for. A tenant switch drops
152
+ * the in-memory modules AND unregisters these, so the incoming tenant's bundle
153
+ * re-registers into a clean registry instead of being refused as a duplicate
154
+ * of the previous tenant's tools.
155
+ */
156
+ private registeredToolPluginIds = new Set<string>();
157
+
158
+ /** bundleUrl → the trusted declaration last recorded on its disk-cache entry
159
+ * this session, so a memory hit with unchanged grants skips the index I/O. */
160
+ private recordedDeclarations = new Map<string, string>();
161
+
162
+ /**
163
+ * Enforce the in-memory tenant boundary. Call at the top of every public load
164
+ * path, BEFORE any cache read.
165
+ *
166
+ * FAIL CLOSED: without a tenant identity we cannot prove a cached module is
167
+ * this session's, so we drop everything and refuse rather than risk serving
168
+ * one tenant's evaluated code to another. On a tenant change we drop the
169
+ * previous tenant's modules (and their tool registrations) before proceeding.
170
+ */
171
+ /**
172
+ * Monotonic load generation. Bumped on every in-memory wipe (a tenant change,
173
+ * fail-closed, or clearAll). A load captures it at ENTRY; a completion whose
174
+ * generation no longer matches is stale and is discarded before it can commit.
175
+ * This closes the async race the sequential guard cannot: tenant A's in-flight
176
+ * loadModule can resolve AFTER a switch to B — clearing `inflight` does not
177
+ * cancel that Promise — and would otherwise write A's module / provenance /
178
+ * device tools into the singleton now marked cacheTenant=B.
179
+ */
180
+ private epoch = 0;
181
+
182
+ private requireTenant(): string {
183
+ const tenant = getTenant();
184
+ if (!tenant) {
185
+ this.dropInMemory();
186
+ this.cacheTenant = null;
187
+ throw new Error(
188
+ '[BundleManager] refusing to load a tenant-scoped bundle without a tenant identity (fail closed)',
189
+ );
190
+ }
191
+ if (tenant !== this.cacheTenant) {
192
+ // First load, or a tenant switch: the caches hold the previous tenant's
193
+ // evaluated modules — none of it may cross the boundary.
194
+ this.dropInMemory();
195
+ this.cacheTenant = tenant;
196
+ }
197
+ return tenant;
198
+ }
199
+
200
+ /** Drop every in-memory cache and unregister the device tools this manager
201
+ * registered — used on a tenant change and by clearAll(). */
202
+ private dropInMemory(): void {
203
+ for (const pluginId of this.registeredToolPluginIds) {
204
+ try { unregisterDeviceTools(pluginId); } catch { /* ignore */ }
205
+ }
206
+ this.registeredToolPluginIds.clear();
207
+ this.recordedDeclarations.clear();
208
+ this.modules.clear();
209
+ this.provenance.clear();
210
+ this.inflight.clear();
211
+ // Bump the generation: any load that started before this wipe is now stale
212
+ // and must not commit its results (see epoch / stale check in loadModule).
213
+ this.epoch++;
214
+ }
215
+
216
+ /** True once the boundary has moved since a load captured `epoch` — i.e. a
217
+ * tenant switch (or wipe) happened while this load was in flight. Its results
218
+ * belong to a tenant that is no longer current and must be discarded. */
219
+ private isStale(epoch: number, tenant: string): boolean {
220
+ return this.epoch !== epoch || getTenant() !== tenant;
221
+ }
222
+
223
+ /** Record a memory serve: the running bytes are the already-evaluated module,
224
+ * and `expected` is refreshed from the manifest hash the caller JUST passed —
225
+ * so after a redeploy the record reads hash !== expected (STALE) instead of
226
+ * the old in-sync pair. The ONE place both memory paths (load()'s early return
227
+ * and loadModule's) record reuse. No-op if nothing was recorded yet. */
228
+ private markMemoryHit(bundleUrl: string, expectedHash?: string): void {
229
+ const prev = this.provenance.get(bundleUrl);
230
+ if (prev) {
231
+ this.provenance.set(bundleUrl, { ...prev, source: 'memory', expected: expectedHash?.toLowerCase() ?? prev.expected });
232
+ }
233
+ }
234
+
100
235
  // ── Public API ──────────────────────────────────────────────
101
236
 
102
237
  /**
@@ -107,29 +242,68 @@ class BundleManager {
107
242
  * provided for any bundle the user has not previously verified — without
108
243
  * it, a tampered bundle is `eval`'d as JS with full RN runtime + every
109
244
  * native capability the manifest declares.
245
+ *
246
+ * `grants` is what the MANIFEST grants this plugin (resolvePluginGrants) — the
247
+ * only authority its device tools are registered under. Omitted (e.g. a
248
+ * prewarm from the slim plugin list) → the bundle is loaded but its tools are
249
+ * NOT registered: without the manifest there is nothing trusted to gate them
250
+ * on, and registering with nothing would downgrade a registration the
251
+ * manifest-driven tool loader already made.
110
252
  */
111
253
  async load(
112
254
  pluginId: string,
113
255
  bundleUrl: string,
114
256
  version: string,
115
257
  expectedHash?: string,
258
+ grants?: PluginGrants,
116
259
  ): Promise<RegisteredUIPlugin> {
260
+ // Tenant boundary FIRST — fail closed without a tenant, drop the previous
261
+ // tenant's modules on a switch, and CAPTURE the tenant + generation at entry
262
+ // so a switch during the async load is caught before any result is used.
263
+ const tenant = this.requireTenant();
264
+ const epoch = this.epoch;
265
+
117
266
  // 1. In-memory cache — instant
118
267
  const cached = this.getComponentFromMemory(pluginId, bundleUrl);
119
- if (cached) return cached;
268
+ if (cached) {
269
+ // Record the reuse. load() returns HERE, before loadModule, so without
270
+ // this the provenance would freeze on the first download/disk source and
271
+ // getLoadedBundles() would never report a memory serve. (loadModule's
272
+ // own memory branch covers the loadTools path, which has no early return.)
273
+ this.markMemoryHit(bundleUrl, expectedHash);
274
+ if (grants) await this.commitTools(pluginId, bundleUrl, grants, tenant, epoch);
275
+ return cached;
276
+ }
120
277
 
121
278
  // 2. Deduplicate in-flight downloads for same bundleUrl
122
279
  let modulePromise = this.inflight.get(bundleUrl);
123
280
  if (!modulePromise) {
124
- modulePromise = this.loadModule(bundleUrl, version, expectedHash);
125
- this.inflight.set(bundleUrl, modulePromise);
281
+ const promise = this.loadModule(bundleUrl, version, expectedHash, tenant, epoch);
282
+ modulePromise = promise;
283
+ this.inflight.set(bundleUrl, promise);
126
284
  // Swallow rejection on the cleanup chain — the original `await` below
127
- // still surfaces the error to the caller.
128
- modulePromise.catch(() => {}).finally(() => this.inflight.delete(bundleUrl));
285
+ // still surfaces the error to the caller. Delete ONLY our own entry: a
286
+ // tenant switch clears `inflight` and the incoming tenant installs a NEW
287
+ // promise for the same url, so an unconditional delete here would evict
288
+ // B's live entry when stale A finally settles — breaking dedup and letting
289
+ // a duplicate B load start.
290
+ promise.catch(() => {}).finally(() => {
291
+ if (this.inflight.get(bundleUrl) === promise) this.inflight.delete(bundleUrl);
292
+ });
129
293
  }
130
294
 
131
295
  await modulePromise;
132
296
 
297
+ // The switch could have landed while we awaited. Even if another tenant has
298
+ // since populated modules[bundleUrl], this load must not return that code.
299
+ if (this.isStale(epoch, tenant)) {
300
+ throw new Error('[BundleManager] tenant changed while loading — discarding (fail closed)');
301
+ }
302
+
303
+ // Tools register at LOAD, not at mount — and for a UI bundle too, so a hybrid
304
+ // plugin's tools are callable whether or not its component is found.
305
+ if (grants) await this.commitTools(pluginId, bundleUrl, grants, tenant, epoch);
306
+
133
307
  // 3. Extract component for this specific pluginId
134
308
  const component = this.getComponentFromMemory(pluginId, bundleUrl);
135
309
  if (!component) {
@@ -142,6 +316,61 @@ class BundleManager {
142
316
  return component;
143
317
  }
144
318
 
319
+ /**
320
+ * Load a bundle for its `tools/` section only — no UI required.
321
+ *
322
+ * Same download, same integrity check, same evaluation as `load()`; the only
323
+ * difference is that a bundle exporting tools and no `Component` is a success
324
+ * here rather than "no component found". Returns the tool names registered.
325
+ *
326
+ * `grants` is REQUIRED: the tools are registered under `pluginId` with exactly
327
+ * what the manifest grants (see load()), never what the bundle claims.
328
+ */
329
+ async loadTools(
330
+ pluginId: string,
331
+ bundleUrl: string,
332
+ version: string,
333
+ expectedHash: string | undefined,
334
+ grants: PluginGrants,
335
+ ): Promise<string[]> {
336
+ if (!grants) {
337
+ throw new Error(`[BundleManager] loadTools(${pluginId}) needs the manifest's grants (resolvePluginGrants) — refusing to register tools on the bundle's own claim`);
338
+ }
339
+ // Tenant boundary FIRST — same fail-closed / drop-on-switch as load(), and
340
+ // capture the tenant + generation for the stale check after the async load.
341
+ const tenant = this.requireTenant();
342
+ const epoch = this.epoch;
343
+ let modulePromise = this.inflight.get(bundleUrl);
344
+ if (!modulePromise) {
345
+ const promise = this.loadModule(bundleUrl, version, expectedHash, tenant, epoch);
346
+ modulePromise = promise;
347
+ this.inflight.set(bundleUrl, promise);
348
+ // Delete ONLY our own entry — see load() for why an unconditional delete
349
+ // lets stale A evict B's live inflight promise across a tenant switch.
350
+ promise.catch(() => {}).finally(() => {
351
+ if (this.inflight.get(bundleUrl) === promise) this.inflight.delete(bundleUrl);
352
+ });
353
+ }
354
+ await modulePromise;
355
+
356
+ if (this.isStale(epoch, tenant)) {
357
+ throw new Error('[BundleManager] tenant changed while loading tools — discarding (fail closed)');
358
+ }
359
+ await this.commitTools(pluginId, bundleUrl, grants, tenant, epoch);
360
+
361
+ const names = listDeviceTools()
362
+ .filter(t => t.pluginId === pluginId)
363
+ .map(t => t.name);
364
+
365
+ if (names.length === 0) {
366
+ console.warn(
367
+ `[BundleManager] ${pluginId}: bundle loaded from ${bundleUrl} but registered no tools. ` +
368
+ `Available exports: ${this.describeModule(this.modules.get(bundleUrl))}`,
369
+ );
370
+ }
371
+ return names;
372
+ }
373
+
145
374
  /**
146
375
  * Prewarm: load every plugin bundle in the background after app boot, so a
147
376
  * later open is instant (in-memory hit) instead of round-tripping the
@@ -153,6 +382,8 @@ class BundleManager {
153
382
  bundleUrl: string;
154
383
  version: string;
155
384
  bundleHash?: string;
385
+ /** The manifest's grants, when the caller has the manifest (see load()). */
386
+ grants?: PluginGrants;
156
387
  }>): Promise<void> {
157
388
  const queue = [...items];
158
389
  const concurrency = Math.min(3, queue.length);
@@ -165,7 +396,7 @@ class BundleManager {
165
396
  const it = queue.shift();
166
397
  if (!it) return;
167
398
  try {
168
- await this.load(it.pluginId, it.bundleUrl, it.version, it.bundleHash);
399
+ await this.load(it.pluginId, it.bundleUrl, it.version, it.bundleHash, it.grants);
169
400
  okCount++;
170
401
  } catch (err) {
171
402
  failCount++;
@@ -183,7 +414,10 @@ class BundleManager {
183
414
  * Clear all caches (in-memory + storage).
184
415
  */
185
416
  async clearAll(): Promise<void> {
186
- this.modules.clear();
417
+ // Full reset — in-memory modules + provenance + inflight + the device tools
418
+ // this manager registered, then the tenant marker and the disk storage.
419
+ this.dropInMemory();
420
+ this.cacheTenant = null;
187
421
  try {
188
422
  await getBundleStorage().clearAll();
189
423
  } catch (err) {
@@ -208,11 +442,42 @@ class BundleManager {
208
442
  }
209
443
  }
210
444
 
445
+ /**
446
+ * What every loaded bundle IS, right now: the sha256 of the bytes running,
447
+ * the hash the server advertised, and where they came from.
448
+ *
449
+ * Deliberately not filtered or prettified — this is the raw answer to "did my
450
+ * redeploy reach the phone?", and it is comparable directly against the
451
+ * manifest `bundleHash` and the server's X-Bundle-SHA256 header.
452
+ */
453
+ getLoadedBundles(): LoadedBundleInfo[] {
454
+ return [...this.provenance.values()];
455
+ }
456
+
211
457
  // ── Private: module loading pipeline ────────────────────────
212
458
 
213
- private async loadModule(bundleUrl: string, version: string, expectedHash?: string): Promise<void> {
214
- // Already in memory?
215
- if (this.modules.has(bundleUrl)) return;
459
+ private async loadModule(
460
+ bundleUrl: string,
461
+ version: string,
462
+ expectedHash: string | undefined,
463
+ // The tenant + generation captured at the caller's ENTRY. Every step that
464
+ // commits into the singleton (disk write, evaluate, module/provenance set,
465
+ // tool registration) is gated on these still being current — a switch to
466
+ // another tenant during the download must NOT let this load's results land.
467
+ expectedTenant: string,
468
+ expectedEpoch: number,
469
+ ): Promise<void> {
470
+ // The boundary may already have moved before we even start (a switch landed
471
+ // between requireTenant() and here). Fail closed rather than proceed.
472
+ if (this.isStale(expectedEpoch, expectedTenant)) {
473
+ throw new Error('[BundleManager] tenant changed before load started — discarding (fail closed)');
474
+ }
475
+ // Already in memory? Then the running bytes are whatever this session
476
+ // already evaluated — record the reuse so provenance never goes silent.
477
+ if (this.modules.has(bundleUrl)) {
478
+ this.markMemoryHit(bundleUrl, expectedHash);
479
+ return;
480
+ }
216
481
 
217
482
  const storage = getBundleStorage();
218
483
  const index = await storage.loadIndex();
@@ -220,15 +485,22 @@ class BundleManager {
220
485
 
221
486
  // Check disk cache. A cache hit is only valid when the cached SHA-256
222
487
  // matches the manifest hash exactly — never trust a cached bundle
223
- // without proof.
488
+ // without proof. The file is read via entry.localPath (the content-addressed
489
+ // path recorded at write time), never reconstructed from url+version, so a
490
+ // hit follows the index straight to the right bytes.
224
491
  let content: string | null = null;
492
+ // The SHA-256 that gate-kept THIS content (the manifest hash for a cache hit,
493
+ // the freshly-verified download hash otherwise) — re-checked inline at the
494
+ // eval so a tampered cache file cannot run (see evaluate()).
495
+ let verifiedHash: string | null = null;
496
+ let servedFrom: LoadedBundleSource = 'download';
225
497
  for (const entry of Object.values(index)) {
226
498
  if (entry.bundleUrl === bundleUrl && entry.version === version) {
227
499
  const age = Date.now() - entry.timestamp;
228
500
  const hashOk = expectedLc && entry.bundleHash && entry.bundleHash.toLowerCase() === expectedLc;
229
501
  if (age < CACHE_TTL && hashOk) {
230
502
  content = await storage.readBundle(entry.localPath);
231
- if (content) break;
503
+ if (content) { verifiedHash = entry.bundleHash!.toLowerCase(); servedFrom = 'disk-cache'; break; }
232
504
  }
233
505
  }
234
506
  }
@@ -237,26 +509,191 @@ class BundleManager {
237
509
  if (!content) {
238
510
  const downloaded = await this.download(bundleUrl, expectedLc);
239
511
  content = downloaded.content;
240
- // Save with verified hash
241
- const safeName = bundleUrl.replace(/[^a-zA-Z0-9]/g, '_').slice(-80);
512
+ verifiedHash = downloaded.sha256.toLowerCase();
513
+ // The download is the long await where a tenant switch is most likely to
514
+ // land. If it did, discard NOW — before we stamp a disk-cache entry with
515
+ // the (now wrong) current tenant.
516
+ if (this.isStale(expectedEpoch, expectedTenant)) {
517
+ throw new Error('[BundleManager] tenant changed during download — discarding (fail closed)');
518
+ }
519
+ // Save with verified hash. CONTENT-ADDRESS the file by its own sha256, NOT
520
+ // by url+version: a bundleUrl+version pair is identical across tenants but
521
+ // resolves to DIFFERENT bytes per tenant, so a url+version filename is
522
+ // SHARED between them. A stale tenant-A writeBundle that completes after a
523
+ // switch to B would then clobber B's cached file bytes at that shared path.
524
+ // Keying the file by verifiedHash gives A's bytes and B's bytes distinct
525
+ // paths that can never collide (identical bytes dedup to one file, which is
526
+ // correct). The index entry below — keyed by url, stamped with the captured
527
+ // tenant + hash + this localPath — is unchanged and stays authoritative.
242
528
  try {
243
- const localPath = await storage.writeBundle(`${safeName}@${version}.js`, content);
244
- index[bundleUrl] = {
245
- localPath,
246
- version,
247
- timestamp: Date.now(),
248
- bundleUrl,
249
- bundleHash: downloaded.sha256,
250
- } satisfies BundleCacheEntry;
251
- await storage.saveIndex(index);
529
+ const localPath = await storage.writeBundle(`${verifiedHash}.js`, content);
530
+ // The index entry is the AUTHORITATIVE disk-cache commit: loadFromDiskCache
531
+ // reads the index (a loose file with no entry is never selected), and it
532
+ // trusts the entry's `tenant` stamp as the authorization boundary while
533
+ // passing the entry's OWN hash (an integrity check that cannot fail). So:
534
+ // (a) stamp ownership from the CAPTURED tenant, never live getTenant() —
535
+ // writeBundle awaited, and a switch here would otherwise label A's
536
+ // bytes as B, which the recovery path would later re-run under B;
537
+ // (b) do not publish the entry at all if the tenant/generation moved
538
+ // while we awaited — fail closed, leaving only an orphan file.
539
+ if (!this.isStale(expectedEpoch, expectedTenant)) {
540
+ index[bundleUrl] = {
541
+ localPath,
542
+ version,
543
+ timestamp: Date.now(),
544
+ bundleUrl,
545
+ bundleHash: downloaded.sha256,
546
+ tenant: expectedTenant,
547
+ } satisfies BundleCacheEntry;
548
+ await storage.saveIndex(index);
549
+ }
252
550
  } catch (err) {
253
551
  console.warn('[BundleManager] Cache write error:', err);
254
552
  }
255
553
  }
256
554
 
257
- // Evaluate
258
- const mod = this.evaluate(content, bundleUrl);
555
+ // FINAL boundary check before anything commits into the singleton. This is
556
+ // the one that also covers the disk-cache-hit path (which never entered the
557
+ // download branch above). Everything after this line — evaluate, module set,
558
+ // provenance set, tool registration — writes tenant-scoped state; if the
559
+ // tenant moved while we awaited, none of it may land (fail closed).
560
+ if (this.isStale(expectedEpoch, expectedTenant)) {
561
+ throw new Error('[BundleManager] tenant changed while loading — discarding stale bundle before eval (fail closed)');
562
+ }
563
+
564
+ // Evaluate — the eval re-hashes `content` against verifiedHash first.
565
+ const mod = this.evaluate(content, bundleUrl, verifiedHash);
259
566
  this.modules.set(bundleUrl, mod);
567
+
568
+ // Record WHAT IS RUNNING. Until this existed, nothing on the device or the
569
+ // server could answer "which build is the phone actually executing?" — so a
570
+ // redeploy that failed to arrive was indistinguishable from one that did,
571
+ // and the only diagnosis available was to reinstall and guess.
572
+ // `expected` is the server's advertised manifest hash: when it differs from
573
+ // `hash`, the phone is running something the server did not just describe.
574
+ this.provenance.set(bundleUrl, {
575
+ bundleUrl,
576
+ version,
577
+ hash: verifiedHash,
578
+ expected: expectedLc,
579
+ source: servedFrom,
580
+ // The captured owner — same rule as the disk stamp. The final stale check
581
+ // above guarantees getTenant() === expectedTenant here, but binding it to
582
+ // the captured value keeps the ownership record impossible to mislabel.
583
+ tenant: expectedTenant,
584
+ loadedAt: Date.now(),
585
+ });
586
+ console.log(
587
+ `[BundleManager] RUNNING ${bundleUrl} v${version} sha=${(verifiedHash || 'none').slice(0, 12)} ` +
588
+ `(${servedFrom}${expectedLc && verifiedHash && expectedLc !== verifiedHash ? `, EXPECTED ${expectedLc.slice(0, 12)} — STALE` : ''})`,
589
+ );
590
+ }
591
+
592
+ /**
593
+ * Register THIS plugin's device tools under the TRUSTED declaration, then
594
+ * record that declaration on the disk-cache entry. Runs on every trusted load
595
+ * — memory hit included — so the grants in force are always the latest the
596
+ * manifest gave (a revoked grant stops applying on the next load, not the next
597
+ * app start). Callers run it only after their stale check.
598
+ */
599
+ private async commitTools(
600
+ pluginId: string,
601
+ bundleUrl: string,
602
+ grants: PluginGrants,
603
+ tenant: string,
604
+ epoch: number,
605
+ ): Promise<void> {
606
+ const declaration: TrustedToolDeclaration = {
607
+ pluginId,
608
+ capabilities: grants.capabilities,
609
+ stateDomains: grants.stateDomains,
610
+ };
611
+ this.registerToolsFromModule(this.modules.get(bundleUrl), declaration);
612
+ await this.recordTrustedDeclaration(bundleUrl, declaration, tenant, epoch);
613
+ }
614
+
615
+ /**
616
+ * Put the plugin's own `tools` section in the device tool registry — under the
617
+ * pluginId and grants the MANIFEST resolved, never the bundle's own `id`,
618
+ * `capabilities` or `stateDomains`. A bundle is code the manifest authorised
619
+ * by hash; what it may touch is the manifest's to say, same as its rendered
620
+ * surface (resolvePluginRuntimeProps). Its own `id` would also let it register
621
+ * as another plugin — including the host's platform tools, which a same-id
622
+ * re-registration replaces.
623
+ *
624
+ * Only this plugin's export (the same lookup the component uses) is
625
+ * registered. Another export's tools in the same bundle belong to another
626
+ * plugin and register when THAT plugin's manifest loads it.
627
+ */
628
+ private registerToolsFromModule(mod: any, { pluginId, capabilities, stateDomains }: TrustedToolDeclaration): void {
629
+ if (!mod) return;
630
+
631
+ const own = this.findPluginExport(mod, pluginId, hasTools);
632
+ const others = Object.keys(mod).filter((k) => hasTools(mod[k]) && mod[k] !== own);
633
+ if (others.length > 0) {
634
+ console.warn(
635
+ `[BundleManager] ${pluginId}: not registering tools from export(s) ${others.join(', ')} — ` +
636
+ `they are not this plugin's, and only their own manifest can grant them`,
637
+ );
638
+ }
639
+ if (!own) return;
640
+
641
+ // The bundle's own declaration grants nothing, but a gap between it and the
642
+ // manifest is the author's bug — name it, or the tool just reads "not declared".
643
+ const ungranted = [
644
+ ...(Array.isArray(own.stateDomains) ? own.stateDomains : []).filter((d: any) => !stateDomains?.includes(d)),
645
+ ...Object.keys(own.capabilities || {}).filter((k) => own.capabilities[k] === true && !(capabilities as any)?.[k]),
646
+ ];
647
+ if (ungranted.length > 0) {
648
+ console.warn(
649
+ `[BundleManager] ${pluginId}: bundle declares ${ungranted.join(', ')} but the manifest does not grant it — denied`,
650
+ );
651
+ }
652
+
653
+ try {
654
+ registerDeviceTools(pluginId, own.tools, capabilities, stateDomains);
655
+ // Remember it so a tenant switch (or clearAll) can unregister the OLD
656
+ // tenant's tools before the incoming tenant re-registers.
657
+ this.registeredToolPluginIds.add(pluginId);
658
+ } catch (err) {
659
+ console.warn(
660
+ `[BundleManager] tool registration failed for ${pluginId}:`,
661
+ err instanceof Error ? err.message : String(err),
662
+ );
663
+ }
664
+ }
665
+
666
+ /**
667
+ * Record the trusted declaration on this tenant's disk-cache entry for the
668
+ * bytes RUNNING — the recovery path (tools/loader loadFromDiskCache) has no
669
+ * manifest and re-registers with exactly this. Only onto an entry this tenant
670
+ * owns whose hash is the running hash: the grant was issued for these bytes,
671
+ * and those are the bytes recovery will evaluate. Best-effort — a lost write
672
+ * leaves no `trusted`, which recovery treats as fail closed.
673
+ */
674
+ private async recordTrustedDeclaration(
675
+ bundleUrl: string,
676
+ declaration: TrustedToolDeclaration,
677
+ tenant: string,
678
+ epoch: number,
679
+ ): Promise<void> {
680
+ const key = JSON.stringify(declaration);
681
+ if (this.recordedDeclarations.get(bundleUrl) === key) return;
682
+ try {
683
+ const storage = getBundleStorage();
684
+ const index = await storage.loadIndex();
685
+ const entry = index[bundleUrl];
686
+ const running = this.provenance.get(bundleUrl)?.hash;
687
+ if (!entry || entry.tenant !== tenant || !running || entry.bundleHash?.toLowerCase() !== running) return;
688
+ if (this.isStale(epoch, tenant)) return;
689
+ if (JSON.stringify(entry.trusted) !== key) {
690
+ index[bundleUrl] = { ...entry, trusted: declaration };
691
+ await storage.saveIndex(index);
692
+ }
693
+ this.recordedDeclarations.set(bundleUrl, key);
694
+ } catch (err) {
695
+ console.warn('[BundleManager] could not record trusted declaration:', err);
696
+ }
260
697
  }
261
698
 
262
699
  /**
@@ -309,7 +746,22 @@ class BundleManager {
309
746
  return { content, sha256: computed };
310
747
  }
311
748
 
312
- private evaluate(content: string, bundleUrl: string): any {
749
+ private evaluate(content: string, bundleUrl: string, verifiedHash: string | null): any {
750
+ // R8 SECURITY GATE — mandatory, fail-closed integrity INLINE at the eval, on
751
+ // the exact bytes about to run. The download path verifies too, but a disk-
752
+ // cache hit reads a file whose STORED metadata hash matched; it never re-hashes
753
+ // the actual bytes, so a tampered cache file would execute. Re-hashing here
754
+ // refuses a missing or mismatched hash whatever path produced `content`.
755
+ //
756
+ // NOTE — this is integrity, NOT a sandbox. `new Function` shares this JS
757
+ // realm; a bundle that passes the hash still runs with full in-app authority
758
+ // (its only capability gating is the plugin manifest via gatePluginNative).
759
+ // The hash proves the bytes are the ones the trusted manifest names; it does
760
+ // not isolate them. Publisher SIGNATURE is deliberately NOT required — the
761
+ // manifest hash already comes from an authenticated Core call
762
+ // (docs/decisions/REMOTE_BUNDLE_INTEGRITY.md).
763
+ assertBundleIntegrity(content, verifiedHash, bundleUrl);
764
+
313
765
  // Strip __toESM wrapping — our shim already provides correct ESM objects.
314
766
  // esbuild's __toESM + isNodeMode=1 double-wraps and breaks React 19 hooks.
315
767
  let cleaned = content.replace(
@@ -350,44 +802,37 @@ class BundleManager {
350
802
 
351
803
  // ── Private: component extraction ───────────────────────────
352
804
 
805
+ /** Find a plugin component in a loaded module. */
806
+ private getComponentFromMemory(pluginId: string, bundleUrl: string): RegisteredUIPlugin | null {
807
+ return this.findPluginExport(this.modules.get(bundleUrl), pluginId, hasComponent);
808
+ }
809
+
353
810
  /**
354
- * Find a plugin component in a loaded module.
355
- * Handles: default export, direct key match, multi-plugin .id match.
811
+ * Find THIS plugin's export in a loaded module — the one lookup both the
812
+ * component and the device tools use, so a plugin's UI and its tools always
813
+ * come from the same export. `accept` says what the caller needs from it.
814
+ * Handles: direct key match, PascalCase key, default export, multi-plugin .id match.
356
815
  */
357
- private getComponentFromMemory(pluginId: string, bundleUrl: string): RegisteredUIPlugin | null {
358
- const mod = this.modules.get(bundleUrl);
816
+ private findPluginExport(mod: any, pluginId: string, accept: (val: any) => boolean): any | null {
359
817
  if (!mod) return null;
360
818
 
361
819
  const shortName = pluginId.includes(':') ? pluginId.split(':').pop()! : pluginId;
362
820
 
363
821
  // 1. Direct key match (full pluginId as key)
364
- if (mod[pluginId] && typeof mod[pluginId].Component === 'function') {
365
- return mod[pluginId] as RegisteredUIPlugin;
366
- }
822
+ if (accept(mod[pluginId])) return mod[pluginId];
367
823
 
368
824
  // 2. PascalCase match from shortName (most reliable for combined bundles)
369
825
  // e.g., "memories-panel" → "MemoriesPanel"
370
826
  const pascal = shortName.replace(/(^|-)([a-z])/g, (_: string, __: string, c: string) => c.toUpperCase());
371
- if (mod[pascal] && typeof mod[pascal] === 'object' && typeof mod[pascal].Component === 'function') {
372
- return mod[pascal] as RegisteredUIPlugin;
373
- }
827
+ if (accept(mod[pascal])) return mod[pascal];
374
828
 
375
829
  // 3. Default export (single-plugin bundles)
376
- if (mod.default && typeof mod.default === 'object' && typeof mod.default.Component === 'function') {
377
- return mod.default as RegisteredUIPlugin;
378
- }
830
+ if (accept(mod.default)) return mod.default;
379
831
 
380
832
  // 4. Match by .id field (last resort — can be unreliable in combined bundles)
381
833
  for (const key of Object.keys(mod)) {
382
834
  const val = mod[key];
383
- if (
384
- val &&
385
- typeof val === 'object' &&
386
- val.id === shortName &&
387
- typeof val.Component === 'function'
388
- ) {
389
- return val as RegisteredUIPlugin;
390
- }
835
+ if (val && (val.id === pluginId || val.id === shortName) && accept(val)) return val;
391
836
  }
392
837
 
393
838
  return null;