@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.
- package/dist/{chunk-PKDQLNBL.mjs → chunk-SICFDA5K.mjs} +388 -70
- package/dist/chunk-SICFDA5K.mjs.map +1 -0
- package/dist/index.d.mts +462 -46
- package/dist/index.d.ts +462 -46
- package/dist/index.js +429 -230
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +20 -157
- package/dist/index.mjs.map +1 -1
- package/dist/{loader-5LUVXUY2.mjs → loader-MSZ4PVTO.mjs} +2 -2
- package/package.json +2 -2
- package/src/api/plugins.ts +15 -9
- package/src/bundles/bundleManager.ts +187 -64
- package/src/bundles/bundleModuleNames.ts +52 -0
- package/src/bundles/bundleUrl.ts +41 -0
- package/src/bundles/storage.ts +71 -0
- package/src/deviceStateHost.ts +25 -0
- package/src/hooks/usePlugin.ts +15 -4
- package/src/host.ts +2 -0
- package/src/index.ts +16 -37
- package/src/pluginRuntime.ts +22 -11
- package/src/sdk/CameraPreview.tsx +7 -0
- package/src/sdk/device-state.ts +120 -17
- package/src/sdk/index.ts +41 -0
- package/src/sdk/registration.ts +2 -1
- package/src/sdk/types.ts +162 -5
- package/src/sdk/voice.ts +82 -0
- package/src/tools/loader.ts +28 -8
- package/src/tools/registry.ts +14 -7
- package/src/voiceHost.ts +2 -0
- package/dist/chunk-PKDQLNBL.mjs.map +0 -1
- /package/dist/{loader-5LUVXUY2.mjs.map → loader-MSZ4PVTO.mjs.map} +0 -0
|
@@ -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 ===
|
|
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
|
-
/**
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
|
|
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
|
|
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
|
|
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
|
|
397
|
-
|
|
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(
|
|
400
|
-
delete index[
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
*
|
|
562
|
-
*
|
|
563
|
-
*
|
|
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,
|
|
647
|
+
private registerToolsFromModule(mod: any, { pluginId, capabilities, stateDomains }: TrustedToolDeclaration): void {
|
|
566
648
|
if (!mod) return;
|
|
567
649
|
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
|
696
|
-
*
|
|
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
|
|
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]
|
|
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]
|
|
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
|
|
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
|
+
}
|
package/src/bundles/storage.ts
CHANGED
|
@@ -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>;
|
package/src/deviceStateHost.ts
CHANGED
|
@@ -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]);
|