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

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';
@@ -23,10 +23,21 @@ import SurfaceFullscreen from '../surfaces/SurfaceFullscreen';
23
23
  import { useSurfaceStore } from '../registry/store';
24
24
  import SurfaceDispatcher from '../registry/SurfaceDispatcher';
25
25
  import { resolveBundleModule } from '../host';
26
+ import { PLUGIN_SDK_MODULE, sdkModuleRefusal } from './bundleModuleNames';
26
27
  import { sha256Hex } from '../utils/sha256';
27
28
  import { assertBundleIntegrity } from './integrity';
28
- import { getBundleStorage, type BundleCacheEntry } from './storage';
29
+ import { getBundleStorage, loadCacheIndex, type BundleCacheEntry, type TrustedToolDeclaration } from './storage';
30
+ import { pluginBundleUrl, isPluginBundleUrl } from './bundleUrl';
31
+
32
+ /** The key a plugin's bundle is held under: its ONE canonical URL whenever the
33
+ * URL given is that plugin's route in any spelling (MGAP-C8). Any other URL is
34
+ * used as given. Every public load enters through this, so no caller, whichever
35
+ * spelling it holds, can make one plugin load twice. */
36
+ function bundleKey(pluginId: string, bundleUrl: string): string {
37
+ return isPluginBundleUrl(bundleUrl, pluginId) ? pluginBundleUrl(pluginId) : bundleUrl;
38
+ }
29
39
  import { registerDeviceTools, unregisterDeviceTools, listDeviceTools } from '../tools/registry';
40
+ import type { PluginGrants } from '../pluginRuntime';
30
41
  import type { RegisteredUIPlugin } from '../sdk/types';
31
42
 
32
43
  // ── Require shim ─────────────────────────────────────────────
@@ -34,7 +45,9 @@ import type { RegisteredUIPlugin } from '../sdk/types';
34
45
  // so they emit require() calls that this shim resolves at runtime. Anything
35
46
  // beyond the built-ins (react-native-webview, expo-linking, …) must be
36
47
  // registered by the host via registerBundleModules() — the host is the one
37
- // that actually has those modules installed.
48
+ // that actually has those modules installed. Any other name in the SDK's own
49
+ // namespaces ('@ateam-ai-mobile/*', '@adas/*') fails the evaluation BY NAME —
50
+ // see bundleModuleNames.ts for why that, and only that, is not warn-and-{}.
38
51
 
39
52
  const ReactShim: any = {
40
53
  __esModule: true,
@@ -79,7 +92,9 @@ const PluginSDKShim = Object.freeze({
79
92
  const requireShim = (name: string) => {
80
93
  if (name === 'react') return ReactShim;
81
94
  if (name === 'react-native') return ReactNative;
82
- if (name === '@adas/plugin-sdk') return PluginSDKShim;
95
+ if (name === PLUGIN_SDK_MODULE) return PluginSDKShim;
96
+ const refusal = sdkModuleRefusal(name);
97
+ if (refusal) throw new Error(`[BundleManager] ${refusal}`);
83
98
  const hostModule = resolveBundleModule(name);
84
99
  if (hostModule !== undefined) return hostModule;
85
100
  console.warn(`[BundleManager] Unknown module: ${name} — register it via registerBundleModules()`);
@@ -90,6 +105,12 @@ const requireShim = (name: string) => {
90
105
 
91
106
  const CACHE_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days
92
107
 
108
+ // What findPluginExport looks for: a renderable plugin, or one with a `tools` section.
109
+ const hasComponent = (val: any): boolean =>
110
+ !!val && typeof val === 'object' && typeof val.Component === 'function';
111
+ const hasTools = (val: any): boolean =>
112
+ !!val && typeof val === 'object' && Array.isArray(val.tools) && val.tools.length > 0;
113
+
93
114
  // ── BundleManager singleton ──────────────────────────────────
94
115
 
95
116
  /** Where the bytes currently running came from. */
@@ -148,6 +169,10 @@ class BundleManager {
148
169
  */
149
170
  private registeredToolPluginIds = new Set<string>();
150
171
 
172
+ /** bundleUrl → the trusted declaration last recorded on its disk-cache entry
173
+ * this session, so a memory hit with unchanged grants skips the index I/O. */
174
+ private recordedDeclarations = new Map<string, string>();
175
+
151
176
  /**
152
177
  * Enforce the in-memory tenant boundary. Call at the top of every public load
153
178
  * path, BEFORE any cache read.
@@ -193,6 +218,7 @@ class BundleManager {
193
218
  try { unregisterDeviceTools(pluginId); } catch { /* ignore */ }
194
219
  }
195
220
  this.registeredToolPluginIds.clear();
221
+ this.recordedDeclarations.clear();
196
222
  this.modules.clear();
197
223
  this.provenance.clear();
198
224
  this.inflight.clear();
@@ -208,13 +234,15 @@ class BundleManager {
208
234
  return this.epoch !== epoch || getTenant() !== tenant;
209
235
  }
210
236
 
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 {
237
+ /** Record a memory serve: the running bytes are the already-evaluated module,
238
+ * and `expected` is refreshed from the manifest hash the caller JUST passed —
239
+ * so after a redeploy the record reads hash !== expected (STALE) instead of
240
+ * the old in-sync pair. The ONE place both memory paths (load()'s early return
241
+ * and loadModule's) record reuse. No-op if nothing was recorded yet. */
242
+ private markMemoryHit(bundleUrl: string, expectedHash?: string): void {
215
243
  const prev = this.provenance.get(bundleUrl);
216
- if (prev && prev.source !== 'memory') {
217
- this.provenance.set(bundleUrl, { ...prev, source: 'memory' });
244
+ if (prev) {
245
+ this.provenance.set(bundleUrl, { ...prev, source: 'memory', expected: expectedHash?.toLowerCase() ?? prev.expected });
218
246
  }
219
247
  }
220
248
 
@@ -228,13 +256,22 @@ class BundleManager {
228
256
  * provided for any bundle the user has not previously verified — without
229
257
  * it, a tampered bundle is `eval`'d as JS with full RN runtime + every
230
258
  * native capability the manifest declares.
259
+ *
260
+ * `grants` is what the MANIFEST grants this plugin (resolvePluginGrants) — the
261
+ * only authority its device tools are registered under. Omitted (e.g. a
262
+ * prewarm from the slim plugin list) → the bundle is loaded but its tools are
263
+ * NOT registered: without the manifest there is nothing trusted to gate them
264
+ * on, and registering with nothing would downgrade a registration the
265
+ * manifest-driven tool loader already made.
231
266
  */
232
267
  async load(
233
268
  pluginId: string,
234
269
  bundleUrl: string,
235
270
  version: string,
236
271
  expectedHash?: string,
272
+ grants?: PluginGrants,
237
273
  ): Promise<RegisteredUIPlugin> {
274
+ bundleUrl = bundleKey(pluginId, bundleUrl);
238
275
  // Tenant boundary FIRST — fail closed without a tenant, drop the previous
239
276
  // tenant's modules on a switch, and CAPTURE the tenant + generation at entry
240
277
  // so a switch during the async load is caught before any result is used.
@@ -248,7 +285,8 @@ class BundleManager {
248
285
  // this the provenance would freeze on the first download/disk source and
249
286
  // getLoadedBundles() would never report a memory serve. (loadModule's
250
287
  // own memory branch covers the loadTools path, which has no early return.)
251
- this.markMemoryHit(bundleUrl);
288
+ this.markMemoryHit(bundleUrl, expectedHash);
289
+ if (grants) await this.commitTools(pluginId, bundleUrl, grants, tenant, epoch);
252
290
  return cached;
253
291
  }
254
292
 
@@ -277,6 +315,10 @@ class BundleManager {
277
315
  throw new Error('[BundleManager] tenant changed while loading — discarding (fail closed)');
278
316
  }
279
317
 
318
+ // Tools register at LOAD, not at mount — and for a UI bundle too, so a hybrid
319
+ // plugin's tools are callable whether or not its component is found.
320
+ if (grants) await this.commitTools(pluginId, bundleUrl, grants, tenant, epoch);
321
+
280
322
  // 3. Extract component for this specific pluginId
281
323
  const component = this.getComponentFromMemory(pluginId, bundleUrl);
282
324
  if (!component) {
@@ -295,13 +337,21 @@ class BundleManager {
295
337
  * Same download, same integrity check, same evaluation as `load()`; the only
296
338
  * difference is that a bundle exporting tools and no `Component` is a success
297
339
  * here rather than "no component found". Returns the tool names registered.
340
+ *
341
+ * `grants` is REQUIRED: the tools are registered under `pluginId` with exactly
342
+ * what the manifest grants (see load()), never what the bundle claims.
298
343
  */
299
344
  async loadTools(
300
345
  pluginId: string,
301
346
  bundleUrl: string,
302
347
  version: string,
303
- expectedHash?: string,
348
+ expectedHash: string | undefined,
349
+ grants: PluginGrants,
304
350
  ): Promise<string[]> {
351
+ if (!grants) {
352
+ throw new Error(`[BundleManager] loadTools(${pluginId}) needs the manifest's grants (resolvePluginGrants) — refusing to register tools on the bundle's own claim`);
353
+ }
354
+ bundleUrl = bundleKey(pluginId, bundleUrl);
305
355
  // Tenant boundary FIRST — same fail-closed / drop-on-switch as load(), and
306
356
  // capture the tenant + generation for the stale check after the async load.
307
357
  const tenant = this.requireTenant();
@@ -322,6 +372,7 @@ class BundleManager {
322
372
  if (this.isStale(epoch, tenant)) {
323
373
  throw new Error('[BundleManager] tenant changed while loading tools — discarding (fail closed)');
324
374
  }
375
+ await this.commitTools(pluginId, bundleUrl, grants, tenant, epoch);
325
376
 
326
377
  const names = listDeviceTools()
327
378
  .filter(t => t.pluginId === pluginId)
@@ -347,6 +398,8 @@ class BundleManager {
347
398
  bundleUrl: string;
348
399
  version: string;
349
400
  bundleHash?: string;
401
+ /** The manifest's grants, when the caller has the manifest (see load()). */
402
+ grants?: PluginGrants;
350
403
  }>): Promise<void> {
351
404
  const queue = [...items];
352
405
  const concurrency = Math.min(3, queue.length);
@@ -359,7 +412,7 @@ class BundleManager {
359
412
  const it = queue.shift();
360
413
  if (!it) return;
361
414
  try {
362
- await this.load(it.pluginId, it.bundleUrl, it.version, it.bundleHash);
415
+ await this.load(it.pluginId, it.bundleUrl, it.version, it.bundleHash, it.grants);
363
416
  okCount++;
364
417
  } catch (err) {
365
418
  failCount++;
@@ -393,11 +446,14 @@ class BundleManager {
393
446
  */
394
447
  async clearPlugin(pluginId: string): Promise<void> {
395
448
  const storage = getBundleStorage();
396
- const index = await storage.loadIndex();
397
- const entry = index[pluginId];
449
+ const index = await loadCacheIndex(storage);
450
+ // The index is keyed by bundle URL (0ea266d4), so the plugin's entry is
451
+ // under its canonical URL, never under its id.
452
+ const bundleUrl = pluginBundleUrl(pluginId);
453
+ const entry = index[bundleUrl];
398
454
  if (entry) {
399
- this.modules.delete(entry.bundleUrl);
400
- delete index[pluginId];
455
+ this.modules.delete(bundleUrl);
456
+ delete index[bundleUrl];
401
457
  await storage.saveIndex(index);
402
458
  try {
403
459
  await storage.deleteBundle(entry.localPath);
@@ -438,13 +494,12 @@ class BundleManager {
438
494
  // Already in memory? Then the running bytes are whatever this session
439
495
  // already evaluated — record the reuse so provenance never goes silent.
440
496
  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 });
497
+ this.markMemoryHit(bundleUrl, expectedHash);
443
498
  return;
444
499
  }
445
500
 
446
501
  const storage = getBundleStorage();
447
- const index = await storage.loadIndex();
502
+ const index = await loadCacheIndex(storage);
448
503
  const expectedLc = expectedHash ? expectedHash.toLowerCase() : null;
449
504
 
450
505
  // Check disk cache. A cache hit is only valid when the cached SHA-256
@@ -551,37 +606,112 @@ class BundleManager {
551
606
  `[BundleManager] RUNNING ${bundleUrl} v${version} sha=${(verifiedHash || 'none').slice(0, 12)} ` +
552
607
  `(${servedFrom}${expectedLc && verifiedHash && expectedLc !== verifiedHash ? `, EXPECTED ${expectedLc.slice(0, 12)} — STALE` : ''})`,
553
608
  );
609
+ }
554
610
 
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);
611
+ /**
612
+ * Register THIS plugin's device tools under the TRUSTED declaration, then
613
+ * record that declaration on the disk-cache entry. Runs on every trusted load
614
+ * — memory hit included — so the grants in force are always the latest the
615
+ * manifest gave (a revoked grant stops applying on the next load, not the next
616
+ * app start). Callers run it only after their stale check.
617
+ */
618
+ private async commitTools(
619
+ pluginId: string,
620
+ bundleUrl: string,
621
+ grants: PluginGrants,
622
+ tenant: string,
623
+ epoch: number,
624
+ ): Promise<void> {
625
+ const declaration: TrustedToolDeclaration = {
626
+ pluginId,
627
+ capabilities: grants.capabilities,
628
+ stateDomains: grants.stateDomains,
629
+ };
630
+ this.registerToolsFromModule(this.modules.get(bundleUrl), declaration);
631
+ await this.recordTrustedDeclaration(bundleUrl, declaration, tenant, epoch);
558
632
  }
559
633
 
560
634
  /**
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.
635
+ * Put the plugin's own `tools` section in the device tool registry — under the
636
+ * pluginId and grants the MANIFEST resolved, never the bundle's own `id`,
637
+ * `capabilities` or `stateDomains`. A bundle is code the manifest authorised
638
+ * by hash; what it may touch is the manifest's to say, same as its rendered
639
+ * surface (resolvePluginRuntimeProps). Its own `id` would also let it register
640
+ * as another plugin — including the host's platform tools, which a same-id
641
+ * re-registration replaces.
642
+ *
643
+ * Only this plugin's export (the same lookup the component uses) is
644
+ * registered. Another export's tools in the same bundle belong to another
645
+ * plugin and register when THAT plugin's manifest loads it.
564
646
  */
565
- private registerToolsFromModule(mod: any, bundleUrl: string): void {
647
+ private registerToolsFromModule(mod: any, { pluginId, capabilities, stateDomains }: TrustedToolDeclaration): void {
566
648
  if (!mod) return;
567
649
 
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
- );
650
+ const own = this.findPluginExport(mod, pluginId, hasTools);
651
+ const others = Object.keys(mod).filter((k) => hasTools(mod[k]) && mod[k] !== own);
652
+ if (others.length > 0) {
653
+ console.warn(
654
+ `[BundleManager] ${pluginId}: not registering tools from export(s) ${others.join(', ')} — ` +
655
+ `they are not this plugin's, and only their own manifest can grant them`,
656
+ );
657
+ }
658
+ if (!own) return;
659
+
660
+ // The bundle's own declaration grants nothing, but a gap between it and the
661
+ // manifest is the author's bug — name it, or the tool just reads "not declared".
662
+ const ungranted = [
663
+ ...(Array.isArray(own.stateDomains) ? own.stateDomains : []).filter((d: any) => !stateDomains?.includes(d)),
664
+ ...Object.keys(own.capabilities || {}).filter((k) => own.capabilities[k] === true && !(capabilities as any)?.[k]),
665
+ ];
666
+ if (ungranted.length > 0) {
667
+ console.warn(
668
+ `[BundleManager] ${pluginId}: bundle declares ${ungranted.join(', ')} but the manifest does not grant it — denied`,
669
+ );
670
+ }
671
+
672
+ try {
673
+ registerDeviceTools(pluginId, own.tools, capabilities, stateDomains);
674
+ // Remember it so a tenant switch (or clearAll) can unregister the OLD
675
+ // tenant's tools before the incoming tenant re-registers.
676
+ this.registeredToolPluginIds.add(pluginId);
677
+ } catch (err) {
678
+ console.warn(
679
+ `[BundleManager] tool registration failed for ${pluginId}:`,
680
+ err instanceof Error ? err.message : String(err),
681
+ );
682
+ }
683
+ }
684
+
685
+ /**
686
+ * Record the trusted declaration on this tenant's disk-cache entry for the
687
+ * bytes RUNNING — the recovery path (tools/loader loadFromDiskCache) has no
688
+ * manifest and re-registers with exactly this. Only onto an entry this tenant
689
+ * owns whose hash is the running hash: the grant was issued for these bytes,
690
+ * and those are the bytes recovery will evaluate. Best-effort — a lost write
691
+ * leaves no `trusted`, which recovery treats as fail closed.
692
+ */
693
+ private async recordTrustedDeclaration(
694
+ bundleUrl: string,
695
+ declaration: TrustedToolDeclaration,
696
+ tenant: string,
697
+ epoch: number,
698
+ ): Promise<void> {
699
+ const key = JSON.stringify(declaration);
700
+ if (this.recordedDeclarations.get(bundleUrl) === key) return;
701
+ try {
702
+ const storage = getBundleStorage();
703
+ const index = await loadCacheIndex(storage);
704
+ const entry = index[bundleUrl];
705
+ const running = this.provenance.get(bundleUrl)?.hash;
706
+ if (!entry || entry.tenant !== tenant || !running || entry.bundleHash?.toLowerCase() !== running) return;
707
+ if (this.isStale(epoch, tenant)) return;
708
+ if (JSON.stringify(entry.trusted) !== key) {
709
+ index[bundleUrl] = { ...entry, trusted: declaration };
710
+ await storage.saveIndex(index);
584
711
  }
712
+ this.recordedDeclarations.set(bundleUrl, key);
713
+ } catch (err) {
714
+ console.warn('[BundleManager] could not record trusted declaration:', err);
585
715
  }
586
716
  }
587
717
 
@@ -691,44 +821,37 @@ class BundleManager {
691
821
 
692
822
  // ── Private: component extraction ───────────────────────────
693
823
 
824
+ /** Find a plugin component in a loaded module. */
825
+ private getComponentFromMemory(pluginId: string, bundleUrl: string): RegisteredUIPlugin | null {
826
+ return this.findPluginExport(this.modules.get(bundleUrl), pluginId, hasComponent);
827
+ }
828
+
694
829
  /**
695
- * Find a plugin component in a loaded module.
696
- * Handles: default export, direct key match, multi-plugin .id match.
830
+ * Find THIS plugin's export in a loaded module — the one lookup both the
831
+ * component and the device tools use, so a plugin's UI and its tools always
832
+ * come from the same export. `accept` says what the caller needs from it.
833
+ * Handles: direct key match, PascalCase key, default export, multi-plugin .id match.
697
834
  */
698
- private getComponentFromMemory(pluginId: string, bundleUrl: string): RegisteredUIPlugin | null {
699
- const mod = this.modules.get(bundleUrl);
835
+ private findPluginExport(mod: any, pluginId: string, accept: (val: any) => boolean): any | null {
700
836
  if (!mod) return null;
701
837
 
702
838
  const shortName = pluginId.includes(':') ? pluginId.split(':').pop()! : pluginId;
703
839
 
704
840
  // 1. Direct key match (full pluginId as key)
705
- if (mod[pluginId] && typeof mod[pluginId].Component === 'function') {
706
- return mod[pluginId] as RegisteredUIPlugin;
707
- }
841
+ if (accept(mod[pluginId])) return mod[pluginId];
708
842
 
709
843
  // 2. PascalCase match from shortName (most reliable for combined bundles)
710
844
  // e.g., "memories-panel" → "MemoriesPanel"
711
845
  const pascal = shortName.replace(/(^|-)([a-z])/g, (_: string, __: string, c: string) => c.toUpperCase());
712
- if (mod[pascal] && typeof mod[pascal] === 'object' && typeof mod[pascal].Component === 'function') {
713
- return mod[pascal] as RegisteredUIPlugin;
714
- }
846
+ if (accept(mod[pascal])) return mod[pascal];
715
847
 
716
848
  // 3. Default export (single-plugin bundles)
717
- if (mod.default && typeof mod.default === 'object' && typeof mod.default.Component === 'function') {
718
- return mod.default as RegisteredUIPlugin;
719
- }
849
+ if (accept(mod.default)) return mod.default;
720
850
 
721
851
  // 4. Match by .id field (last resort — can be unreliable in combined bundles)
722
852
  for (const key of Object.keys(mod)) {
723
853
  const val = mod[key];
724
- if (
725
- val &&
726
- typeof val === 'object' &&
727
- val.id === shortName &&
728
- typeof val.Component === 'function'
729
- ) {
730
- return val as RegisteredUIPlugin;
731
- }
854
+ if (val && (val.id === pluginId || val.id === shortName) && accept(val)) return val;
732
855
  }
733
856
 
734
857
  return null;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The SDK's module names, as a remote bundle sees them.
3
+ *
4
+ * A bundle keeps its SDK external, and the require shim (bundleManager) answers
5
+ * exactly ONE SDK name: '@adas/plugin-sdk'. The same code is published to npm as
6
+ * '@ateam-ai-mobile/plugin-runtime' — but that is the HOST app's dependency, and
7
+ * inside a bundle it resolves to nothing.
8
+ *
9
+ * Until MGAP-C2 (2026-09-27) the shim answered that name the way it answers any
10
+ * unknown module: a console warning and `{}`. So a bundle doing
11
+ * `import { CameraPreview } from '@ateam-ai-mobile/plugin-runtime'` evaluated
12
+ * cleanly, got `undefined`, and crashed on first render as "undefined is not a
13
+ * component" — nowhere near the import that caused it. The app's in-app
14
+ * reference HUD imports it that way (it is host code, compiled with the app, so
15
+ * the name resolves there), and that is where an agent copied it from.
16
+ *
17
+ * So the SDK's own namespaces fail BY NAME at evaluation:
18
+ * - '@ateam-ai-mobile/*' — the host's packages, never a bundle's.
19
+ * - '@adas/*' other than '@adas/plugin-sdk' — the SDK's own docs once told
20
+ * plugin authors to import '@adas/device-state' (sdk/device-state.ts header,
21
+ * 4110943), a name the shim never resolved. There is one SDK module name.
22
+ * Neither is aliased to '@adas/plugin-sdk': two names for one SDK is a second
23
+ * source of truth, and that is what this closes (CORE's call). The shim checks
24
+ * them BEFORE the host's module registry, so no host registration can alias one
25
+ * either.
26
+ *
27
+ * Every OTHER unknown module keeps the warning and `{}`. What exists there is
28
+ * the host's decision, not the SDK's — each host registers its own set
29
+ * (@ateam-ai-mobile/expo adds expo-web-browser and expo-linking, the reference
30
+ * app adds react-native-webview) — and a bundle built for one host may keep
31
+ * such a module external and only use it on a path another host never takes.
32
+ * Failing those at evaluation would break already-deployed bundles we have no
33
+ * inventory of, and there is no single right name to point the author at.
34
+ */
35
+
36
+ /** The one SDK module name a bundle can require (values and types alike). */
37
+ export const PLUGIN_SDK_MODULE = '@adas/plugin-sdk';
38
+
39
+ /**
40
+ * The by-name refusal for a require of an SDK-namespace name the shim will
41
+ * never satisfy, or null when the name is the SDK itself or not the SDK's.
42
+ */
43
+ export function sdkModuleRefusal(name: string): string | null {
44
+ if (name === PLUGIN_SDK_MODULE) return null;
45
+ if (!name.startsWith('@ateam-ai-mobile/') && !name.startsWith('@adas/')) return null;
46
+ return (
47
+ `a plugin bundle required '${name}', which does not exist inside a bundle. ` +
48
+ `Import from '${PLUGIN_SDK_MODULE}' instead — it is the only SDK module a bundle can require ` +
49
+ `(values and types alike), and the one to keep external when building. ` +
50
+ `'@ateam-ai-mobile/*' are the host app's packages, not the plugin's.`
51
+ );
52
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * THE bundle URL of a plugin: the one place it is spelled.
3
+ *
4
+ * Core serves a plugin's React Native bundle at
5
+ * `/api/ui-plugins/<pluginId>/bundle.js`, where the id is URL-encoded because
6
+ * plugin ids contain ':' (`mcp:<connector>:<plugin>`). The bundle manager keys
7
+ * everything it holds by this string: evaluated modules, in-flight downloads and
8
+ * the disk index. To it, two spellings of one plugin's URL are two plugins, which
9
+ * means two downloads, two evaluations and two separate copies of module state.
10
+ *
11
+ * That happened (MGAP-C8). The rendered surface derived the encoded route, while
12
+ * the device-tool loader (6294cc3) used the manifest's `bundleUrl`, which Core
13
+ * fills in UNENCODED. A plugin that was on screen and also had device tools ran
14
+ * as two module instances, and its tools never saw its UI's state.
15
+ *
16
+ * So the URL is DERIVED from the plugin id and never read from a manifest (a
17
+ * connector-supplied URL is ignored, as in ea435149). Every path that builds a
18
+ * plugin's bundle URL, or looks one up, calls this function.
19
+ */
20
+ export function pluginBundleUrl(pluginId: string): string {
21
+ return `/api/ui-plugins/${encodeURIComponent(pluginId)}/bundle.js`;
22
+ }
23
+
24
+ /**
25
+ * Is `url` this plugin's bundle URL, however the id is spelled in it?
26
+ *
27
+ * Core's manifest writes the id unencoded (`/api/ui-plugins/mcp:c:p/bundle.js`).
28
+ * That is the same route as `pluginBundleUrl(pluginId)`, but it is not a key the
29
+ * SDK may use. The check compares decoded forms, so it asks "same route?" without
30
+ * parsing the URL. A URL that cannot be decoded is not a match (fail closed).
31
+ */
32
+ export function isPluginBundleUrl(url: unknown, pluginId: string): boolean {
33
+ if (typeof url !== 'string') return false;
34
+ const canonical = pluginBundleUrl(pluginId);
35
+ if (url === canonical) return true;
36
+ try {
37
+ return decodeURIComponent(url) === decodeURIComponent(canonical);
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
@@ -5,6 +5,8 @@
5
5
  * default is in-memory (bundles re-download per app session — correct,
6
6
  * just slower on cold start).
7
7
  */
8
+ import type { PluginGrants } from '../pluginRuntime';
9
+ import { pluginBundleUrl, isPluginBundleUrl } from './bundleUrl';
8
10
 
9
11
  export interface BundleCacheEntry {
10
12
  localPath: string;
@@ -27,6 +29,24 @@ export interface BundleCacheEntry {
27
29
  * untrusted. Never read a missing tenant as "mine".
28
30
  */
29
31
  tenant?: string;
32
+ /**
33
+ * The trusted declaration these bytes' device tools were last registered
34
+ * under: the pluginId and grants resolved from the server manifest that named
35
+ * this bundle (resolvePluginGrants). Recorded so the disk-cache recovery path —
36
+ * which runs exactly when there is NO manifest — re-registers with the same
37
+ * authority instead of the bundle's own claim.
38
+ *
39
+ * Absent → no trusted declaration is known for these bytes, so recovery skips
40
+ * the entry (fail closed) and the catalog path registers it once the owning
41
+ * connector answers again.
42
+ */
43
+ trusted?: TrustedToolDeclaration;
44
+ }
45
+
46
+ /** Who a bundle's device tools belong to and what they are granted — always
47
+ * from the manifest, never from the bundle. */
48
+ export interface TrustedToolDeclaration extends PluginGrants {
49
+ pluginId: string;
30
50
  }
31
51
 
32
52
  /**
@@ -55,6 +75,57 @@ export function selectOwnedCacheEntries<T extends { tenant?: string }>(
55
75
  return (entries || []).filter((e) => !!e && e.tenant === tenant);
56
76
  }
57
77
 
78
+ /**
79
+ * Read the disk index with every entry filed under its plugin's ONE bundle URL
80
+ * (pluginBundleUrl). The SDK reads the index only through this function.
81
+ *
82
+ * Before MGAP-C8, the device-tool loader recorded entries under the manifest's
83
+ * UNENCODED spelling (`/api/ui-plugins/mcp:c:p/bundle.js`), while the rendered
84
+ * surface used the encoded one. A device that ran both can therefore hold one
85
+ * plugin under two keys. Every load now asks for the canonical URL, so an entry
86
+ * stored under the other spelling would never be read again.
87
+ *
88
+ * The decision is to RECOVER such an entry rather than discard it, but only when
89
+ * it provably is the same bundle. An entry moves to the canonical key only when
90
+ * both of these hold:
91
+ * (a) it carries the trusted declaration that names its plugin. That record
92
+ * comes from the manifest, never from the bundle.
93
+ * (b) its URL is that plugin's route in another spelling (isPluginBundleUrl).
94
+ * Its tenant stamp, hash and trusted declaration move with it unchanged. So its
95
+ * ownership (selectOwnedCacheEntries) and its integrity (the hash is re-checked
96
+ * at eval) stay exactly as they were. When both spellings hold an entry, the
97
+ * NEWER one is kept. That is the same last-writer-wins rule one key always had.
98
+ *
99
+ * Everything else stays where it is and is never adopted:
100
+ * - an entry with no trusted declaration (its plugin is unknown without parsing
101
+ * its URL);
102
+ * - a URL that is not the plugin's route at all, such as a connector-declared
103
+ * URL the old loader followed.
104
+ * Such an entry is simply never matched. Its plugin downloads once more under
105
+ * the canonical key, and the hash gate still applies.
106
+ *
107
+ * The index is mutated in place (the manager does read-modify-write on it), and
108
+ * the move reaches disk with the next index write.
109
+ */
110
+ export async function loadCacheIndex(
111
+ storage: BundleStorage = getBundleStorage(),
112
+ ): Promise<Record<string, BundleCacheEntry>> {
113
+ const index = (await storage.loadIndex()) || {};
114
+ for (const [key, entry] of Object.entries(index)) {
115
+ const pluginId = entry?.trusted?.pluginId;
116
+ if (!pluginId) continue;
117
+ const canonical = pluginBundleUrl(pluginId);
118
+ if (key === canonical) continue;
119
+ if (!isPluginBundleUrl(key, pluginId) || !isPluginBundleUrl(entry.bundleUrl, pluginId)) continue;
120
+ delete index[key];
121
+ const held = index[canonical];
122
+ if (!held || (entry.timestamp ?? 0) > (held.timestamp ?? 0)) {
123
+ index[canonical] = { ...entry, bundleUrl: canonical };
124
+ }
125
+ }
126
+ return index;
127
+ }
128
+
58
129
  export interface BundleStorage {
59
130
  loadIndex(): Promise<Record<string, BundleCacheEntry>>;
60
131
  saveIndex(index: Record<string, BundleCacheEntry>): Promise<void>;
@@ -191,6 +191,31 @@ export function gateDeviceState(declared?: readonly DeviceBridgeDomain[]): Devic
191
191
  return out as DeviceState;
192
192
  }
193
193
 
194
+ /**
195
+ * The `deviceState` a device TOOL call receives: the same domains, but
196
+ * `subscribe` refuses.
197
+ *
198
+ * A declared domain IS the engine, and the engines live as long as the app. A
199
+ * UI plugin has a mount to unsubscribe in; a tool call has nothing — a listener
200
+ * `run()` adds outlives it and runs on every later event, once per call that
201
+ * added one. Refused loudly rather than quietly scoped to the call: a
202
+ * subscription that stops hearing when `run` returns reads as "nothing has
203
+ * changed". Everything else passes through untouched.
204
+ */
205
+ export function withoutSubscribe(state: DeviceState): DeviceState {
206
+ if (!state) return state;
207
+ const out: any = {};
208
+ for (const domain of ALL_DOMAINS) {
209
+ const d = (state as any)[domain];
210
+ out[domain] = d && Object.assign(Object.create(d), {
211
+ subscribe() {
212
+ throw denial(domain, 'a device tool call has no lifetime to own a subscription. Subscribe from the plugin UI, or read getSnapshot() within the call.');
213
+ },
214
+ });
215
+ }
216
+ return out as DeviceState;
217
+ }
218
+
194
219
  /** Which domains this build can actually answer — for diagnostics. */
195
220
  export function implementedStateDomains(): DeviceBridgeDomain[] {
196
221
  return ALL_DOMAINS.filter(d => !!(_provider as any)[d]);