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

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.
@@ -0,0 +1,9 @@
1
+ import {
2
+ ensureDeviceToolsLoaded,
3
+ resetDeviceToolLoader
4
+ } from "./chunk-PKDQLNBL.mjs";
5
+ export {
6
+ ensureDeviceToolsLoaded,
7
+ resetDeviceToolLoader
8
+ };
9
+ //# sourceMappingURL=loader-5LUVXUY2.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ateam-ai-mobile/plugin-runtime",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "ADAS plugin runtime for React Native hosts — plugin SDK, bundle pipeline with integrity verification, host bridge, surfaces. No Expo dependencies.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -9,7 +9,7 @@
9
9
  "build": "tsup"
10
10
  },
11
11
  "dependencies": {
12
- "@ateam-ai-mobile/core": "^0.1.5",
12
+ "@ateam-ai-mobile/core": "0.1.7",
13
13
  "zustand": "^5.0.0"
14
14
  },
15
15
  "peerDependencies": {
@@ -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,9 @@ 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 { assertBundleIntegrity } from './integrity';
27
28
  import { getBundleStorage, type BundleCacheEntry } from './storage';
29
+ import { registerDeviceTools, unregisterDeviceTools, listDeviceTools } from '../tools/registry';
28
30
  import type { RegisteredUIPlugin } from '../sdk/types';
29
31
 
30
32
  // ── Require shim ─────────────────────────────────────────────
@@ -90,13 +92,132 @@ const CACHE_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days
90
92
 
91
93
  // ── BundleManager singleton ──────────────────────────────────
92
94
 
95
+ /** Where the bytes currently running came from. */
96
+ export type LoadedBundleSource = 'download' | 'disk-cache' | 'memory';
97
+
98
+ /**
99
+ * Provenance of one loaded bundle — what is RUNNING, not what was requested.
100
+ *
101
+ * `hash` is the sha256 of the evaluated bytes. `expected` is the hash the
102
+ * server's manifest advertised for that url. Equal means the phone is running
103
+ * the build the server currently describes; different means it is not, and the
104
+ * pair says so without needing a rebuild, a reinstall or a guess.
105
+ */
106
+ export interface LoadedBundleInfo {
107
+ bundleUrl: string;
108
+ version: string;
109
+ /** sha256 (hex, lowercase) of the bytes that were evaluated. */
110
+ hash: string | null;
111
+ /** sha256 the server advertised for this url at load time, if any. */
112
+ expected: string | null;
113
+ source: LoadedBundleSource;
114
+ tenant: string | null;
115
+ loadedAt: number;
116
+ }
117
+
93
118
  class BundleManager {
94
- /** In-memory module cache: bundleUrl → evaluated module.exports */
119
+ /** In-memory module cache: bundleUrl → evaluated module.exports. Holds ONE
120
+ * tenant's modules at a time — the tenant boundary is `cacheTenant` /
121
+ * `requireTenant()`, NOT the key. */
95
122
  private modules = new Map<string, any>();
123
+ /** What is actually RUNNING, keyed by bundleUrl (for the current tenant). See
124
+ * getLoadedBundles(). */
125
+ private provenance = new Map<string, LoadedBundleInfo>();
96
126
 
97
127
  /** In-flight download deduplication: bundleUrl → Promise */
98
128
  private inflight = new Map<string, Promise<any>>();
99
129
 
130
+ /**
131
+ * The tenant the in-memory caches (modules / provenance / inflight) currently
132
+ * belong to. This is the tenant BOUNDARY for evaluated code.
133
+ *
134
+ * A bundleUrl resolves PER-TENANT on the server — one url, different bytes for
135
+ * different tenants — so a memory hit keyed on url alone would hand tenant B
136
+ * the module tenant A already evaluated and ran. PR #9 scoped the DISK cache
137
+ * (per-tenant index + hash gate); the in-memory map was left url-keyed. This
138
+ * closes that: the caches only ever hold ONE tenant's modules, and a switch
139
+ * drops them before anything is served.
140
+ */
141
+ private cacheTenant: string | null = null;
142
+
143
+ /**
144
+ * pluginIds this manager registered device tools for. A tenant switch drops
145
+ * the in-memory modules AND unregisters these, so the incoming tenant's bundle
146
+ * re-registers into a clean registry instead of being refused as a duplicate
147
+ * of the previous tenant's tools.
148
+ */
149
+ private registeredToolPluginIds = new Set<string>();
150
+
151
+ /**
152
+ * Enforce the in-memory tenant boundary. Call at the top of every public load
153
+ * path, BEFORE any cache read.
154
+ *
155
+ * FAIL CLOSED: without a tenant identity we cannot prove a cached module is
156
+ * this session's, so we drop everything and refuse rather than risk serving
157
+ * one tenant's evaluated code to another. On a tenant change we drop the
158
+ * previous tenant's modules (and their tool registrations) before proceeding.
159
+ */
160
+ /**
161
+ * Monotonic load generation. Bumped on every in-memory wipe (a tenant change,
162
+ * fail-closed, or clearAll). A load captures it at ENTRY; a completion whose
163
+ * generation no longer matches is stale and is discarded before it can commit.
164
+ * This closes the async race the sequential guard cannot: tenant A's in-flight
165
+ * loadModule can resolve AFTER a switch to B — clearing `inflight` does not
166
+ * cancel that Promise — and would otherwise write A's module / provenance /
167
+ * device tools into the singleton now marked cacheTenant=B.
168
+ */
169
+ private epoch = 0;
170
+
171
+ private requireTenant(): string {
172
+ const tenant = getTenant();
173
+ if (!tenant) {
174
+ this.dropInMemory();
175
+ this.cacheTenant = null;
176
+ throw new Error(
177
+ '[BundleManager] refusing to load a tenant-scoped bundle without a tenant identity (fail closed)',
178
+ );
179
+ }
180
+ if (tenant !== this.cacheTenant) {
181
+ // First load, or a tenant switch: the caches hold the previous tenant's
182
+ // evaluated modules — none of it may cross the boundary.
183
+ this.dropInMemory();
184
+ this.cacheTenant = tenant;
185
+ }
186
+ return tenant;
187
+ }
188
+
189
+ /** Drop every in-memory cache and unregister the device tools this manager
190
+ * registered — used on a tenant change and by clearAll(). */
191
+ private dropInMemory(): void {
192
+ for (const pluginId of this.registeredToolPluginIds) {
193
+ try { unregisterDeviceTools(pluginId); } catch { /* ignore */ }
194
+ }
195
+ this.registeredToolPluginIds.clear();
196
+ this.modules.clear();
197
+ this.provenance.clear();
198
+ this.inflight.clear();
199
+ // Bump the generation: any load that started before this wipe is now stale
200
+ // and must not commit its results (see epoch / stale check in loadModule).
201
+ this.epoch++;
202
+ }
203
+
204
+ /** True once the boundary has moved since a load captured `epoch` — i.e. a
205
+ * tenant switch (or wipe) happened while this load was in flight. Its results
206
+ * belong to a tenant that is no longer current and must be discarded. */
207
+ private isStale(epoch: number, tenant: string): boolean {
208
+ return this.epoch !== epoch || getTenant() !== tenant;
209
+ }
210
+
211
+ /** Flip an existing provenance record to source:'memory' — a later serve of an
212
+ * already-evaluated bundle came from the in-memory cache, not a fresh
213
+ * download/disk read. No-op if nothing was recorded yet. */
214
+ private markMemoryHit(bundleUrl: string): void {
215
+ const prev = this.provenance.get(bundleUrl);
216
+ if (prev && prev.source !== 'memory') {
217
+ this.provenance.set(bundleUrl, { ...prev, source: 'memory' });
218
+ }
219
+ }
220
+
100
221
  // ── Public API ──────────────────────────────────────────────
101
222
 
102
223
  /**
@@ -114,22 +235,48 @@ class BundleManager {
114
235
  version: string,
115
236
  expectedHash?: string,
116
237
  ): Promise<RegisteredUIPlugin> {
238
+ // Tenant boundary FIRST — fail closed without a tenant, drop the previous
239
+ // tenant's modules on a switch, and CAPTURE the tenant + generation at entry
240
+ // so a switch during the async load is caught before any result is used.
241
+ const tenant = this.requireTenant();
242
+ const epoch = this.epoch;
243
+
117
244
  // 1. In-memory cache — instant
118
245
  const cached = this.getComponentFromMemory(pluginId, bundleUrl);
119
- if (cached) return cached;
246
+ if (cached) {
247
+ // Record the reuse. load() returns HERE, before loadModule, so without
248
+ // this the provenance would freeze on the first download/disk source and
249
+ // getLoadedBundles() would never report a memory serve. (loadModule's
250
+ // own memory branch covers the loadTools path, which has no early return.)
251
+ this.markMemoryHit(bundleUrl);
252
+ return cached;
253
+ }
120
254
 
121
255
  // 2. Deduplicate in-flight downloads for same bundleUrl
122
256
  let modulePromise = this.inflight.get(bundleUrl);
123
257
  if (!modulePromise) {
124
- modulePromise = this.loadModule(bundleUrl, version, expectedHash);
125
- this.inflight.set(bundleUrl, modulePromise);
258
+ const promise = this.loadModule(bundleUrl, version, expectedHash, tenant, epoch);
259
+ modulePromise = promise;
260
+ this.inflight.set(bundleUrl, promise);
126
261
  // 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));
262
+ // still surfaces the error to the caller. Delete ONLY our own entry: a
263
+ // tenant switch clears `inflight` and the incoming tenant installs a NEW
264
+ // promise for the same url, so an unconditional delete here would evict
265
+ // B's live entry when stale A finally settles — breaking dedup and letting
266
+ // a duplicate B load start.
267
+ promise.catch(() => {}).finally(() => {
268
+ if (this.inflight.get(bundleUrl) === promise) this.inflight.delete(bundleUrl);
269
+ });
129
270
  }
130
271
 
131
272
  await modulePromise;
132
273
 
274
+ // The switch could have landed while we awaited. Even if another tenant has
275
+ // since populated modules[bundleUrl], this load must not return that code.
276
+ if (this.isStale(epoch, tenant)) {
277
+ throw new Error('[BundleManager] tenant changed while loading — discarding (fail closed)');
278
+ }
279
+
133
280
  // 3. Extract component for this specific pluginId
134
281
  const component = this.getComponentFromMemory(pluginId, bundleUrl);
135
282
  if (!component) {
@@ -142,6 +289,53 @@ class BundleManager {
142
289
  return component;
143
290
  }
144
291
 
292
+ /**
293
+ * Load a bundle for its `tools/` section only — no UI required.
294
+ *
295
+ * Same download, same integrity check, same evaluation as `load()`; the only
296
+ * difference is that a bundle exporting tools and no `Component` is a success
297
+ * here rather than "no component found". Returns the tool names registered.
298
+ */
299
+ async loadTools(
300
+ pluginId: string,
301
+ bundleUrl: string,
302
+ version: string,
303
+ expectedHash?: string,
304
+ ): Promise<string[]> {
305
+ // Tenant boundary FIRST — same fail-closed / drop-on-switch as load(), and
306
+ // capture the tenant + generation for the stale check after the async load.
307
+ const tenant = this.requireTenant();
308
+ const epoch = this.epoch;
309
+ let modulePromise = this.inflight.get(bundleUrl);
310
+ if (!modulePromise) {
311
+ const promise = this.loadModule(bundleUrl, version, expectedHash, tenant, epoch);
312
+ modulePromise = promise;
313
+ this.inflight.set(bundleUrl, promise);
314
+ // Delete ONLY our own entry — see load() for why an unconditional delete
315
+ // lets stale A evict B's live inflight promise across a tenant switch.
316
+ promise.catch(() => {}).finally(() => {
317
+ if (this.inflight.get(bundleUrl) === promise) this.inflight.delete(bundleUrl);
318
+ });
319
+ }
320
+ await modulePromise;
321
+
322
+ if (this.isStale(epoch, tenant)) {
323
+ throw new Error('[BundleManager] tenant changed while loading tools — discarding (fail closed)');
324
+ }
325
+
326
+ const names = listDeviceTools()
327
+ .filter(t => t.pluginId === pluginId)
328
+ .map(t => t.name);
329
+
330
+ if (names.length === 0) {
331
+ console.warn(
332
+ `[BundleManager] ${pluginId}: bundle loaded from ${bundleUrl} but registered no tools. ` +
333
+ `Available exports: ${this.describeModule(this.modules.get(bundleUrl))}`,
334
+ );
335
+ }
336
+ return names;
337
+ }
338
+
145
339
  /**
146
340
  * Prewarm: load every plugin bundle in the background after app boot, so a
147
341
  * later open is instant (in-memory hit) instead of round-tripping the
@@ -183,7 +377,10 @@ class BundleManager {
183
377
  * Clear all caches (in-memory + storage).
184
378
  */
185
379
  async clearAll(): Promise<void> {
186
- this.modules.clear();
380
+ // Full reset — in-memory modules + provenance + inflight + the device tools
381
+ // this manager registered, then the tenant marker and the disk storage.
382
+ this.dropInMemory();
383
+ this.cacheTenant = null;
187
384
  try {
188
385
  await getBundleStorage().clearAll();
189
386
  } catch (err) {
@@ -208,11 +405,43 @@ class BundleManager {
208
405
  }
209
406
  }
210
407
 
408
+ /**
409
+ * What every loaded bundle IS, right now: the sha256 of the bytes running,
410
+ * the hash the server advertised, and where they came from.
411
+ *
412
+ * Deliberately not filtered or prettified — this is the raw answer to "did my
413
+ * redeploy reach the phone?", and it is comparable directly against the
414
+ * manifest `bundleHash` and the server's X-Bundle-SHA256 header.
415
+ */
416
+ getLoadedBundles(): LoadedBundleInfo[] {
417
+ return [...this.provenance.values()];
418
+ }
419
+
211
420
  // ── Private: module loading pipeline ────────────────────────
212
421
 
213
- private async loadModule(bundleUrl: string, version: string, expectedHash?: string): Promise<void> {
214
- // Already in memory?
215
- if (this.modules.has(bundleUrl)) return;
422
+ private async loadModule(
423
+ bundleUrl: string,
424
+ version: string,
425
+ expectedHash: string | undefined,
426
+ // The tenant + generation captured at the caller's ENTRY. Every step that
427
+ // commits into the singleton (disk write, evaluate, module/provenance set,
428
+ // tool registration) is gated on these still being current — a switch to
429
+ // another tenant during the download must NOT let this load's results land.
430
+ expectedTenant: string,
431
+ expectedEpoch: number,
432
+ ): Promise<void> {
433
+ // The boundary may already have moved before we even start (a switch landed
434
+ // between requireTenant() and here). Fail closed rather than proceed.
435
+ if (this.isStale(expectedEpoch, expectedTenant)) {
436
+ throw new Error('[BundleManager] tenant changed before load started — discarding (fail closed)');
437
+ }
438
+ // Already in memory? Then the running bytes are whatever this session
439
+ // already evaluated — record the reuse so provenance never goes silent.
440
+ if (this.modules.has(bundleUrl)) {
441
+ const prev = this.provenance.get(bundleUrl);
442
+ if (prev) this.provenance.set(bundleUrl, { ...prev, source: 'memory', expected: expectedHash?.toLowerCase() ?? prev.expected });
443
+ return;
444
+ }
216
445
 
217
446
  const storage = getBundleStorage();
218
447
  const index = await storage.loadIndex();
@@ -220,15 +449,22 @@ class BundleManager {
220
449
 
221
450
  // Check disk cache. A cache hit is only valid when the cached SHA-256
222
451
  // matches the manifest hash exactly — never trust a cached bundle
223
- // without proof.
452
+ // without proof. The file is read via entry.localPath (the content-addressed
453
+ // path recorded at write time), never reconstructed from url+version, so a
454
+ // hit follows the index straight to the right bytes.
224
455
  let content: string | null = null;
456
+ // The SHA-256 that gate-kept THIS content (the manifest hash for a cache hit,
457
+ // the freshly-verified download hash otherwise) — re-checked inline at the
458
+ // eval so a tampered cache file cannot run (see evaluate()).
459
+ let verifiedHash: string | null = null;
460
+ let servedFrom: LoadedBundleSource = 'download';
225
461
  for (const entry of Object.values(index)) {
226
462
  if (entry.bundleUrl === bundleUrl && entry.version === version) {
227
463
  const age = Date.now() - entry.timestamp;
228
464
  const hashOk = expectedLc && entry.bundleHash && entry.bundleHash.toLowerCase() === expectedLc;
229
465
  if (age < CACHE_TTL && hashOk) {
230
466
  content = await storage.readBundle(entry.localPath);
231
- if (content) break;
467
+ if (content) { verifiedHash = entry.bundleHash!.toLowerCase(); servedFrom = 'disk-cache'; break; }
232
468
  }
233
469
  }
234
470
  }
@@ -237,26 +473,116 @@ class BundleManager {
237
473
  if (!content) {
238
474
  const downloaded = await this.download(bundleUrl, expectedLc);
239
475
  content = downloaded.content;
240
- // Save with verified hash
241
- const safeName = bundleUrl.replace(/[^a-zA-Z0-9]/g, '_').slice(-80);
476
+ verifiedHash = downloaded.sha256.toLowerCase();
477
+ // The download is the long await where a tenant switch is most likely to
478
+ // land. If it did, discard NOW — before we stamp a disk-cache entry with
479
+ // the (now wrong) current tenant.
480
+ if (this.isStale(expectedEpoch, expectedTenant)) {
481
+ throw new Error('[BundleManager] tenant changed during download — discarding (fail closed)');
482
+ }
483
+ // Save with verified hash. CONTENT-ADDRESS the file by its own sha256, NOT
484
+ // by url+version: a bundleUrl+version pair is identical across tenants but
485
+ // resolves to DIFFERENT bytes per tenant, so a url+version filename is
486
+ // SHARED between them. A stale tenant-A writeBundle that completes after a
487
+ // switch to B would then clobber B's cached file bytes at that shared path.
488
+ // Keying the file by verifiedHash gives A's bytes and B's bytes distinct
489
+ // paths that can never collide (identical bytes dedup to one file, which is
490
+ // correct). The index entry below — keyed by url, stamped with the captured
491
+ // tenant + hash + this localPath — is unchanged and stays authoritative.
242
492
  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);
493
+ const localPath = await storage.writeBundle(`${verifiedHash}.js`, content);
494
+ // The index entry is the AUTHORITATIVE disk-cache commit: loadFromDiskCache
495
+ // reads the index (a loose file with no entry is never selected), and it
496
+ // trusts the entry's `tenant` stamp as the authorization boundary while
497
+ // passing the entry's OWN hash (an integrity check that cannot fail). So:
498
+ // (a) stamp ownership from the CAPTURED tenant, never live getTenant() —
499
+ // writeBundle awaited, and a switch here would otherwise label A's
500
+ // bytes as B, which the recovery path would later re-run under B;
501
+ // (b) do not publish the entry at all if the tenant/generation moved
502
+ // while we awaited — fail closed, leaving only an orphan file.
503
+ if (!this.isStale(expectedEpoch, expectedTenant)) {
504
+ index[bundleUrl] = {
505
+ localPath,
506
+ version,
507
+ timestamp: Date.now(),
508
+ bundleUrl,
509
+ bundleHash: downloaded.sha256,
510
+ tenant: expectedTenant,
511
+ } satisfies BundleCacheEntry;
512
+ await storage.saveIndex(index);
513
+ }
252
514
  } catch (err) {
253
515
  console.warn('[BundleManager] Cache write error:', err);
254
516
  }
255
517
  }
256
518
 
257
- // Evaluate
258
- const mod = this.evaluate(content, bundleUrl);
519
+ // FINAL boundary check before anything commits into the singleton. This is
520
+ // the one that also covers the disk-cache-hit path (which never entered the
521
+ // download branch above). Everything after this line — evaluate, module set,
522
+ // provenance set, tool registration — writes tenant-scoped state; if the
523
+ // tenant moved while we awaited, none of it may land (fail closed).
524
+ if (this.isStale(expectedEpoch, expectedTenant)) {
525
+ throw new Error('[BundleManager] tenant changed while loading — discarding stale bundle before eval (fail closed)');
526
+ }
527
+
528
+ // Evaluate — the eval re-hashes `content` against verifiedHash first.
529
+ const mod = this.evaluate(content, bundleUrl, verifiedHash);
259
530
  this.modules.set(bundleUrl, mod);
531
+
532
+ // Record WHAT IS RUNNING. Until this existed, nothing on the device or the
533
+ // server could answer "which build is the phone actually executing?" — so a
534
+ // redeploy that failed to arrive was indistinguishable from one that did,
535
+ // and the only diagnosis available was to reinstall and guess.
536
+ // `expected` is the server's advertised manifest hash: when it differs from
537
+ // `hash`, the phone is running something the server did not just describe.
538
+ this.provenance.set(bundleUrl, {
539
+ bundleUrl,
540
+ version,
541
+ hash: verifiedHash,
542
+ expected: expectedLc,
543
+ source: servedFrom,
544
+ // The captured owner — same rule as the disk stamp. The final stale check
545
+ // above guarantees getTenant() === expectedTenant here, but binding it to
546
+ // the captured value keeps the ownership record impossible to mislabel.
547
+ tenant: expectedTenant,
548
+ loadedAt: Date.now(),
549
+ });
550
+ console.log(
551
+ `[BundleManager] RUNNING ${bundleUrl} v${version} sha=${(verifiedHash || 'none').slice(0, 12)} ` +
552
+ `(${servedFrom}${expectedLc && verifiedHash && expectedLc !== verifiedHash ? `, EXPECTED ${expectedLc.slice(0, 12)} — STALE` : ''})`,
553
+ );
554
+
555
+ // Tools register at LOAD, not at mount — that is what makes a tool callable
556
+ // with nothing of the plugin on screen.
557
+ this.registerToolsFromModule(mod, bundleUrl);
558
+ }
559
+
560
+ /**
561
+ * Pick up every `tools` section in an evaluated bundle and put it in the
562
+ * device tool registry. Runs for UI, hybrid and tools-only bundles alike: a
563
+ * plugin that ships both gets its tools registered without its UI mounting.
564
+ */
565
+ private registerToolsFromModule(mod: any, bundleUrl: string): void {
566
+ if (!mod) return;
567
+
568
+ for (const key of Object.keys(mod)) {
569
+ const val = mod[key];
570
+ if (!val || typeof val !== 'object' || !Array.isArray(val.tools) || val.tools.length === 0) {
571
+ continue;
572
+ }
573
+ const pluginId = typeof val.id === 'string' ? val.id : `${bundleUrl}#${key}`;
574
+ try {
575
+ registerDeviceTools(pluginId, val.tools, val.capabilities, val.stateDomains);
576
+ // Remember it so a tenant switch (or clearAll) can unregister the OLD
577
+ // tenant's tools before the incoming tenant re-registers.
578
+ this.registeredToolPluginIds.add(pluginId);
579
+ } catch (err) {
580
+ console.warn(
581
+ `[BundleManager] tool registration failed for ${pluginId}:`,
582
+ err instanceof Error ? err.message : String(err),
583
+ );
584
+ }
585
+ }
260
586
  }
261
587
 
262
588
  /**
@@ -309,7 +635,22 @@ class BundleManager {
309
635
  return { content, sha256: computed };
310
636
  }
311
637
 
312
- private evaluate(content: string, bundleUrl: string): any {
638
+ private evaluate(content: string, bundleUrl: string, verifiedHash: string | null): any {
639
+ // R8 SECURITY GATE — mandatory, fail-closed integrity INLINE at the eval, on
640
+ // the exact bytes about to run. The download path verifies too, but a disk-
641
+ // cache hit reads a file whose STORED metadata hash matched; it never re-hashes
642
+ // the actual bytes, so a tampered cache file would execute. Re-hashing here
643
+ // refuses a missing or mismatched hash whatever path produced `content`.
644
+ //
645
+ // NOTE — this is integrity, NOT a sandbox. `new Function` shares this JS
646
+ // realm; a bundle that passes the hash still runs with full in-app authority
647
+ // (its only capability gating is the plugin manifest via gatePluginNative).
648
+ // The hash proves the bytes are the ones the trusted manifest names; it does
649
+ // not isolate them. Publisher SIGNATURE is deliberately NOT required — the
650
+ // manifest hash already comes from an authenticated Core call
651
+ // (docs/decisions/REMOTE_BUNDLE_INTEGRITY.md).
652
+ assertBundleIntegrity(content, verifiedHash, bundleUrl);
653
+
313
654
  // Strip __toESM wrapping — our shim already provides correct ESM objects.
314
655
  // esbuild's __toESM + isNodeMode=1 double-wraps and breaks React 19 hooks.
315
656
  let cleaned = content.replace(
@@ -0,0 +1,29 @@
1
+ /**
2
+ * R8 remote-bundle integrity — the fail-closed check run INLINE, immediately
3
+ * before a remote bundle is evaluated with `new Function`.
4
+ *
5
+ * See docs/decisions/REMOTE_BUNDLE_INTEGRITY.md for the threat model and the
6
+ * (declined) publisher-signature decision. This is INTEGRITY, not a sandbox:
7
+ * it proves the bytes about to run are exactly the ones the trusted manifest
8
+ * hash names; it does not isolate what they can do.
9
+ */
10
+ import { sha256Hex } from '../utils/sha256';
11
+
12
+ /**
13
+ * Refuse to evaluate a bundle whose bytes do not match the SHA-256 that
14
+ * gate-kept them. Fail-closed on a MISSING hash too — an unverifiable bundle is
15
+ * treated exactly like a mismatched one. `verifiedHash` is the manifest hash for
16
+ * a cache hit or the freshly-verified download hash; re-hashing `content` here
17
+ * closes the disk-cache-tamper gap the download-time check leaves open.
18
+ */
19
+ export function assertBundleIntegrity(content: string, verifiedHash: string | null, label: string): void {
20
+ const digest = sha256Hex(content);
21
+ if (!verifiedHash) {
22
+ throw new Error(`[bundle-integrity] refusing to evaluate ${label}: no verified SHA-256 (fail closed).`);
23
+ }
24
+ if (digest !== verifiedHash.toLowerCase()) {
25
+ throw new Error(
26
+ `[bundle-integrity] integrity FAIL at eval for ${label}: content sha256 ${digest.slice(0, 12)}… ≠ verified ${verifiedHash.slice(0, 12)}…. Refusing to evaluate.`,
27
+ );
28
+ }
29
+ }
@@ -13,6 +13,46 @@ export interface BundleCacheEntry {
13
13
  bundleUrl: string;
14
14
  /** SHA-256 (hex, lowercase) of the cached bundle bytes — integrity proof */
15
15
  bundleHash?: string;
16
+ /**
17
+ * Tenant whose session downloaded these bytes.
18
+ *
19
+ * A bundle URL is NOT tenant-independent: `/api/ui-plugins/<id>/bundle.js`
20
+ * resolves per-tenant on the server, so the SAME url returns different code
21
+ * for different tenants. On the normal load path the hash gate catches this
22
+ * for free — the expected hash comes from the current tenant's manifest and
23
+ * will not match another tenant's cached bytes. The disk-cache recovery path
24
+ * has no manifest to check against, so it must discriminate by tenant.
25
+ *
26
+ * Absent on entries written before this field existed: unknown ownership, so
27
+ * untrusted. Never read a missing tenant as "mine".
28
+ */
29
+ tenant?: string;
30
+ }
31
+
32
+ /**
33
+ * Which cached entries belong to `tenant` and may therefore be re-evaluated.
34
+ *
35
+ * Lives here, beside the entry type, so "may I run this cached code" has ONE
36
+ * owner rather than being re-answered by each caller. `loadIndex()` returns
37
+ * every entry regardless of owner — it has to, since the manager looks entries
38
+ * up by url and rewrites them — so any caller that EVALUATES cached code must
39
+ * come through this function first.
40
+ *
41
+ * Fail-closed on both unknowns. An empty tenant owns nothing. An entry with no
42
+ * recorded tenant has unknown ownership and is never assumed to be the current
43
+ * one: every device already in the field has unstamped entries, so reading
44
+ * "missing" as "mine" would keep the hole open exactly where it is hardest to
45
+ * see.
46
+ *
47
+ * Compares the tenant field directly — it never builds a composite key, so
48
+ * there is no separator a tenant name could forge across.
49
+ */
50
+ export function selectOwnedCacheEntries<T extends { tenant?: string }>(
51
+ entries: T[],
52
+ tenant: string | null | undefined,
53
+ ): T[] {
54
+ if (!tenant) return [];
55
+ return (entries || []).filter((e) => !!e && e.tenant === tenant);
16
56
  }
17
57
 
18
58
  export interface BundleStorage {