@ateam-ai-mobile/plugin-runtime 0.1.7 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-PKDQLNBL.mjs → chunk-RXR6M623.mjs} +269 -57
- package/dist/chunk-RXR6M623.mjs.map +1 -0
- package/dist/index.d.mts +356 -36
- package/dist/index.d.ts +356 -36
- package/dist/index.js +288 -161
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +13 -101
- package/dist/index.mjs.map +1 -1
- package/dist/{loader-5LUVXUY2.mjs → loader-UUOPZQSI.mjs} +2 -2
- package/package.json +2 -2
- package/src/bundles/bundleManager.ts +161 -57
- package/src/bundles/storage.ts +19 -0
- package/src/deviceStateHost.ts +25 -0
- package/src/hooks/usePlugin.ts +13 -3
- package/src/host.ts +2 -0
- package/src/index.ts +11 -6
- package/src/pluginRuntime.ts +22 -11
- package/src/sdk/device-state.ts +100 -10
- package/src/sdk/index.ts +7 -0
- package/src/sdk/registration.ts +2 -1
- package/src/sdk/types.ts +149 -5
- package/src/sdk/voice.ts +38 -0
- package/src/tools/loader.ts +24 -6
- package/src/tools/registry.ts +12 -5
- package/src/voiceHost.ts +2 -0
- package/dist/chunk-PKDQLNBL.mjs.map +0 -1
- /package/dist/{loader-5LUVXUY2.mjs.map → loader-UUOPZQSI.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';
|
|
@@ -25,8 +25,9 @@ import SurfaceDispatcher from '../registry/SurfaceDispatcher';
|
|
|
25
25
|
import { resolveBundleModule } from '../host';
|
|
26
26
|
import { sha256Hex } from '../utils/sha256';
|
|
27
27
|
import { assertBundleIntegrity } from './integrity';
|
|
28
|
-
import { getBundleStorage, type BundleCacheEntry } from './storage';
|
|
28
|
+
import { getBundleStorage, type BundleCacheEntry, type TrustedToolDeclaration } from './storage';
|
|
29
29
|
import { registerDeviceTools, unregisterDeviceTools, listDeviceTools } from '../tools/registry';
|
|
30
|
+
import type { PluginGrants } from '../pluginRuntime';
|
|
30
31
|
import type { RegisteredUIPlugin } from '../sdk/types';
|
|
31
32
|
|
|
32
33
|
// ── Require shim ─────────────────────────────────────────────
|
|
@@ -90,6 +91,12 @@ const requireShim = (name: string) => {
|
|
|
90
91
|
|
|
91
92
|
const CACHE_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
92
93
|
|
|
94
|
+
// What findPluginExport looks for: a renderable plugin, or one with a `tools` section.
|
|
95
|
+
const hasComponent = (val: any): boolean =>
|
|
96
|
+
!!val && typeof val === 'object' && typeof val.Component === 'function';
|
|
97
|
+
const hasTools = (val: any): boolean =>
|
|
98
|
+
!!val && typeof val === 'object' && Array.isArray(val.tools) && val.tools.length > 0;
|
|
99
|
+
|
|
93
100
|
// ── BundleManager singleton ──────────────────────────────────
|
|
94
101
|
|
|
95
102
|
/** Where the bytes currently running came from. */
|
|
@@ -148,6 +155,10 @@ class BundleManager {
|
|
|
148
155
|
*/
|
|
149
156
|
private registeredToolPluginIds = new Set<string>();
|
|
150
157
|
|
|
158
|
+
/** bundleUrl → the trusted declaration last recorded on its disk-cache entry
|
|
159
|
+
* this session, so a memory hit with unchanged grants skips the index I/O. */
|
|
160
|
+
private recordedDeclarations = new Map<string, string>();
|
|
161
|
+
|
|
151
162
|
/**
|
|
152
163
|
* Enforce the in-memory tenant boundary. Call at the top of every public load
|
|
153
164
|
* path, BEFORE any cache read.
|
|
@@ -193,6 +204,7 @@ class BundleManager {
|
|
|
193
204
|
try { unregisterDeviceTools(pluginId); } catch { /* ignore */ }
|
|
194
205
|
}
|
|
195
206
|
this.registeredToolPluginIds.clear();
|
|
207
|
+
this.recordedDeclarations.clear();
|
|
196
208
|
this.modules.clear();
|
|
197
209
|
this.provenance.clear();
|
|
198
210
|
this.inflight.clear();
|
|
@@ -208,13 +220,15 @@ class BundleManager {
|
|
|
208
220
|
return this.epoch !== epoch || getTenant() !== tenant;
|
|
209
221
|
}
|
|
210
222
|
|
|
211
|
-
/**
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
|
|
223
|
+
/** Record a memory serve: the running bytes are the already-evaluated module,
|
|
224
|
+
* and `expected` is refreshed from the manifest hash the caller JUST passed —
|
|
225
|
+
* so after a redeploy the record reads hash !== expected (STALE) instead of
|
|
226
|
+
* the old in-sync pair. The ONE place both memory paths (load()'s early return
|
|
227
|
+
* and loadModule's) record reuse. No-op if nothing was recorded yet. */
|
|
228
|
+
private markMemoryHit(bundleUrl: string, expectedHash?: string): void {
|
|
215
229
|
const prev = this.provenance.get(bundleUrl);
|
|
216
|
-
if (prev
|
|
217
|
-
this.provenance.set(bundleUrl, { ...prev, source: 'memory' });
|
|
230
|
+
if (prev) {
|
|
231
|
+
this.provenance.set(bundleUrl, { ...prev, source: 'memory', expected: expectedHash?.toLowerCase() ?? prev.expected });
|
|
218
232
|
}
|
|
219
233
|
}
|
|
220
234
|
|
|
@@ -228,12 +242,20 @@ class BundleManager {
|
|
|
228
242
|
* provided for any bundle the user has not previously verified — without
|
|
229
243
|
* it, a tampered bundle is `eval`'d as JS with full RN runtime + every
|
|
230
244
|
* native capability the manifest declares.
|
|
245
|
+
*
|
|
246
|
+
* `grants` is what the MANIFEST grants this plugin (resolvePluginGrants) — the
|
|
247
|
+
* only authority its device tools are registered under. Omitted (e.g. a
|
|
248
|
+
* prewarm from the slim plugin list) → the bundle is loaded but its tools are
|
|
249
|
+
* NOT registered: without the manifest there is nothing trusted to gate them
|
|
250
|
+
* on, and registering with nothing would downgrade a registration the
|
|
251
|
+
* manifest-driven tool loader already made.
|
|
231
252
|
*/
|
|
232
253
|
async load(
|
|
233
254
|
pluginId: string,
|
|
234
255
|
bundleUrl: string,
|
|
235
256
|
version: string,
|
|
236
257
|
expectedHash?: string,
|
|
258
|
+
grants?: PluginGrants,
|
|
237
259
|
): Promise<RegisteredUIPlugin> {
|
|
238
260
|
// Tenant boundary FIRST — fail closed without a tenant, drop the previous
|
|
239
261
|
// tenant's modules on a switch, and CAPTURE the tenant + generation at entry
|
|
@@ -248,7 +270,8 @@ class BundleManager {
|
|
|
248
270
|
// this the provenance would freeze on the first download/disk source and
|
|
249
271
|
// getLoadedBundles() would never report a memory serve. (loadModule's
|
|
250
272
|
// own memory branch covers the loadTools path, which has no early return.)
|
|
251
|
-
this.markMemoryHit(bundleUrl);
|
|
273
|
+
this.markMemoryHit(bundleUrl, expectedHash);
|
|
274
|
+
if (grants) await this.commitTools(pluginId, bundleUrl, grants, tenant, epoch);
|
|
252
275
|
return cached;
|
|
253
276
|
}
|
|
254
277
|
|
|
@@ -277,6 +300,10 @@ class BundleManager {
|
|
|
277
300
|
throw new Error('[BundleManager] tenant changed while loading — discarding (fail closed)');
|
|
278
301
|
}
|
|
279
302
|
|
|
303
|
+
// Tools register at LOAD, not at mount — and for a UI bundle too, so a hybrid
|
|
304
|
+
// plugin's tools are callable whether or not its component is found.
|
|
305
|
+
if (grants) await this.commitTools(pluginId, bundleUrl, grants, tenant, epoch);
|
|
306
|
+
|
|
280
307
|
// 3. Extract component for this specific pluginId
|
|
281
308
|
const component = this.getComponentFromMemory(pluginId, bundleUrl);
|
|
282
309
|
if (!component) {
|
|
@@ -295,13 +322,20 @@ class BundleManager {
|
|
|
295
322
|
* Same download, same integrity check, same evaluation as `load()`; the only
|
|
296
323
|
* difference is that a bundle exporting tools and no `Component` is a success
|
|
297
324
|
* here rather than "no component found". Returns the tool names registered.
|
|
325
|
+
*
|
|
326
|
+
* `grants` is REQUIRED: the tools are registered under `pluginId` with exactly
|
|
327
|
+
* what the manifest grants (see load()), never what the bundle claims.
|
|
298
328
|
*/
|
|
299
329
|
async loadTools(
|
|
300
330
|
pluginId: string,
|
|
301
331
|
bundleUrl: string,
|
|
302
332
|
version: string,
|
|
303
|
-
expectedHash
|
|
333
|
+
expectedHash: string | undefined,
|
|
334
|
+
grants: PluginGrants,
|
|
304
335
|
): Promise<string[]> {
|
|
336
|
+
if (!grants) {
|
|
337
|
+
throw new Error(`[BundleManager] loadTools(${pluginId}) needs the manifest's grants (resolvePluginGrants) — refusing to register tools on the bundle's own claim`);
|
|
338
|
+
}
|
|
305
339
|
// Tenant boundary FIRST — same fail-closed / drop-on-switch as load(), and
|
|
306
340
|
// capture the tenant + generation for the stale check after the async load.
|
|
307
341
|
const tenant = this.requireTenant();
|
|
@@ -322,6 +356,7 @@ class BundleManager {
|
|
|
322
356
|
if (this.isStale(epoch, tenant)) {
|
|
323
357
|
throw new Error('[BundleManager] tenant changed while loading tools — discarding (fail closed)');
|
|
324
358
|
}
|
|
359
|
+
await this.commitTools(pluginId, bundleUrl, grants, tenant, epoch);
|
|
325
360
|
|
|
326
361
|
const names = listDeviceTools()
|
|
327
362
|
.filter(t => t.pluginId === pluginId)
|
|
@@ -347,6 +382,8 @@ class BundleManager {
|
|
|
347
382
|
bundleUrl: string;
|
|
348
383
|
version: string;
|
|
349
384
|
bundleHash?: string;
|
|
385
|
+
/** The manifest's grants, when the caller has the manifest (see load()). */
|
|
386
|
+
grants?: PluginGrants;
|
|
350
387
|
}>): Promise<void> {
|
|
351
388
|
const queue = [...items];
|
|
352
389
|
const concurrency = Math.min(3, queue.length);
|
|
@@ -359,7 +396,7 @@ class BundleManager {
|
|
|
359
396
|
const it = queue.shift();
|
|
360
397
|
if (!it) return;
|
|
361
398
|
try {
|
|
362
|
-
await this.load(it.pluginId, it.bundleUrl, it.version, it.bundleHash);
|
|
399
|
+
await this.load(it.pluginId, it.bundleUrl, it.version, it.bundleHash, it.grants);
|
|
363
400
|
okCount++;
|
|
364
401
|
} catch (err) {
|
|
365
402
|
failCount++;
|
|
@@ -438,8 +475,7 @@ class BundleManager {
|
|
|
438
475
|
// Already in memory? Then the running bytes are whatever this session
|
|
439
476
|
// already evaluated — record the reuse so provenance never goes silent.
|
|
440
477
|
if (this.modules.has(bundleUrl)) {
|
|
441
|
-
|
|
442
|
-
if (prev) this.provenance.set(bundleUrl, { ...prev, source: 'memory', expected: expectedHash?.toLowerCase() ?? prev.expected });
|
|
478
|
+
this.markMemoryHit(bundleUrl, expectedHash);
|
|
443
479
|
return;
|
|
444
480
|
}
|
|
445
481
|
|
|
@@ -551,37 +587,112 @@ class BundleManager {
|
|
|
551
587
|
`[BundleManager] RUNNING ${bundleUrl} v${version} sha=${(verifiedHash || 'none').slice(0, 12)} ` +
|
|
552
588
|
`(${servedFrom}${expectedLc && verifiedHash && expectedLc !== verifiedHash ? `, EXPECTED ${expectedLc.slice(0, 12)} — STALE` : ''})`,
|
|
553
589
|
);
|
|
590
|
+
}
|
|
554
591
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
592
|
+
/**
|
|
593
|
+
* Register THIS plugin's device tools under the TRUSTED declaration, then
|
|
594
|
+
* record that declaration on the disk-cache entry. Runs on every trusted load
|
|
595
|
+
* — memory hit included — so the grants in force are always the latest the
|
|
596
|
+
* manifest gave (a revoked grant stops applying on the next load, not the next
|
|
597
|
+
* app start). Callers run it only after their stale check.
|
|
598
|
+
*/
|
|
599
|
+
private async commitTools(
|
|
600
|
+
pluginId: string,
|
|
601
|
+
bundleUrl: string,
|
|
602
|
+
grants: PluginGrants,
|
|
603
|
+
tenant: string,
|
|
604
|
+
epoch: number,
|
|
605
|
+
): Promise<void> {
|
|
606
|
+
const declaration: TrustedToolDeclaration = {
|
|
607
|
+
pluginId,
|
|
608
|
+
capabilities: grants.capabilities,
|
|
609
|
+
stateDomains: grants.stateDomains,
|
|
610
|
+
};
|
|
611
|
+
this.registerToolsFromModule(this.modules.get(bundleUrl), declaration);
|
|
612
|
+
await this.recordTrustedDeclaration(bundleUrl, declaration, tenant, epoch);
|
|
558
613
|
}
|
|
559
614
|
|
|
560
615
|
/**
|
|
561
|
-
*
|
|
562
|
-
*
|
|
563
|
-
*
|
|
616
|
+
* Put the plugin's own `tools` section in the device tool registry — under the
|
|
617
|
+
* pluginId and grants the MANIFEST resolved, never the bundle's own `id`,
|
|
618
|
+
* `capabilities` or `stateDomains`. A bundle is code the manifest authorised
|
|
619
|
+
* by hash; what it may touch is the manifest's to say, same as its rendered
|
|
620
|
+
* surface (resolvePluginRuntimeProps). Its own `id` would also let it register
|
|
621
|
+
* as another plugin — including the host's platform tools, which a same-id
|
|
622
|
+
* re-registration replaces.
|
|
623
|
+
*
|
|
624
|
+
* Only this plugin's export (the same lookup the component uses) is
|
|
625
|
+
* registered. Another export's tools in the same bundle belong to another
|
|
626
|
+
* plugin and register when THAT plugin's manifest loads it.
|
|
564
627
|
*/
|
|
565
|
-
private registerToolsFromModule(mod: any,
|
|
628
|
+
private registerToolsFromModule(mod: any, { pluginId, capabilities, stateDomains }: TrustedToolDeclaration): void {
|
|
566
629
|
if (!mod) return;
|
|
567
630
|
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
631
|
+
const own = this.findPluginExport(mod, pluginId, hasTools);
|
|
632
|
+
const others = Object.keys(mod).filter((k) => hasTools(mod[k]) && mod[k] !== own);
|
|
633
|
+
if (others.length > 0) {
|
|
634
|
+
console.warn(
|
|
635
|
+
`[BundleManager] ${pluginId}: not registering tools from export(s) ${others.join(', ')} — ` +
|
|
636
|
+
`they are not this plugin's, and only their own manifest can grant them`,
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
if (!own) return;
|
|
640
|
+
|
|
641
|
+
// The bundle's own declaration grants nothing, but a gap between it and the
|
|
642
|
+
// manifest is the author's bug — name it, or the tool just reads "not declared".
|
|
643
|
+
const ungranted = [
|
|
644
|
+
...(Array.isArray(own.stateDomains) ? own.stateDomains : []).filter((d: any) => !stateDomains?.includes(d)),
|
|
645
|
+
...Object.keys(own.capabilities || {}).filter((k) => own.capabilities[k] === true && !(capabilities as any)?.[k]),
|
|
646
|
+
];
|
|
647
|
+
if (ungranted.length > 0) {
|
|
648
|
+
console.warn(
|
|
649
|
+
`[BundleManager] ${pluginId}: bundle declares ${ungranted.join(', ')} but the manifest does not grant it — denied`,
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
try {
|
|
654
|
+
registerDeviceTools(pluginId, own.tools, capabilities, stateDomains);
|
|
655
|
+
// Remember it so a tenant switch (or clearAll) can unregister the OLD
|
|
656
|
+
// tenant's tools before the incoming tenant re-registers.
|
|
657
|
+
this.registeredToolPluginIds.add(pluginId);
|
|
658
|
+
} catch (err) {
|
|
659
|
+
console.warn(
|
|
660
|
+
`[BundleManager] tool registration failed for ${pluginId}:`,
|
|
661
|
+
err instanceof Error ? err.message : String(err),
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Record the trusted declaration on this tenant's disk-cache entry for the
|
|
668
|
+
* bytes RUNNING — the recovery path (tools/loader loadFromDiskCache) has no
|
|
669
|
+
* manifest and re-registers with exactly this. Only onto an entry this tenant
|
|
670
|
+
* owns whose hash is the running hash: the grant was issued for these bytes,
|
|
671
|
+
* and those are the bytes recovery will evaluate. Best-effort — a lost write
|
|
672
|
+
* leaves no `trusted`, which recovery treats as fail closed.
|
|
673
|
+
*/
|
|
674
|
+
private async recordTrustedDeclaration(
|
|
675
|
+
bundleUrl: string,
|
|
676
|
+
declaration: TrustedToolDeclaration,
|
|
677
|
+
tenant: string,
|
|
678
|
+
epoch: number,
|
|
679
|
+
): Promise<void> {
|
|
680
|
+
const key = JSON.stringify(declaration);
|
|
681
|
+
if (this.recordedDeclarations.get(bundleUrl) === key) return;
|
|
682
|
+
try {
|
|
683
|
+
const storage = getBundleStorage();
|
|
684
|
+
const index = await storage.loadIndex();
|
|
685
|
+
const entry = index[bundleUrl];
|
|
686
|
+
const running = this.provenance.get(bundleUrl)?.hash;
|
|
687
|
+
if (!entry || entry.tenant !== tenant || !running || entry.bundleHash?.toLowerCase() !== running) return;
|
|
688
|
+
if (this.isStale(epoch, tenant)) return;
|
|
689
|
+
if (JSON.stringify(entry.trusted) !== key) {
|
|
690
|
+
index[bundleUrl] = { ...entry, trusted: declaration };
|
|
691
|
+
await storage.saveIndex(index);
|
|
584
692
|
}
|
|
693
|
+
this.recordedDeclarations.set(bundleUrl, key);
|
|
694
|
+
} catch (err) {
|
|
695
|
+
console.warn('[BundleManager] could not record trusted declaration:', err);
|
|
585
696
|
}
|
|
586
697
|
}
|
|
587
698
|
|
|
@@ -691,44 +802,37 @@ class BundleManager {
|
|
|
691
802
|
|
|
692
803
|
// ── Private: component extraction ───────────────────────────
|
|
693
804
|
|
|
805
|
+
/** Find a plugin component in a loaded module. */
|
|
806
|
+
private getComponentFromMemory(pluginId: string, bundleUrl: string): RegisteredUIPlugin | null {
|
|
807
|
+
return this.findPluginExport(this.modules.get(bundleUrl), pluginId, hasComponent);
|
|
808
|
+
}
|
|
809
|
+
|
|
694
810
|
/**
|
|
695
|
-
* Find
|
|
696
|
-
*
|
|
811
|
+
* Find THIS plugin's export in a loaded module — the one lookup both the
|
|
812
|
+
* component and the device tools use, so a plugin's UI and its tools always
|
|
813
|
+
* come from the same export. `accept` says what the caller needs from it.
|
|
814
|
+
* Handles: direct key match, PascalCase key, default export, multi-plugin .id match.
|
|
697
815
|
*/
|
|
698
|
-
private
|
|
699
|
-
const mod = this.modules.get(bundleUrl);
|
|
816
|
+
private findPluginExport(mod: any, pluginId: string, accept: (val: any) => boolean): any | null {
|
|
700
817
|
if (!mod) return null;
|
|
701
818
|
|
|
702
819
|
const shortName = pluginId.includes(':') ? pluginId.split(':').pop()! : pluginId;
|
|
703
820
|
|
|
704
821
|
// 1. Direct key match (full pluginId as key)
|
|
705
|
-
if (mod[pluginId]
|
|
706
|
-
return mod[pluginId] as RegisteredUIPlugin;
|
|
707
|
-
}
|
|
822
|
+
if (accept(mod[pluginId])) return mod[pluginId];
|
|
708
823
|
|
|
709
824
|
// 2. PascalCase match from shortName (most reliable for combined bundles)
|
|
710
825
|
// e.g., "memories-panel" → "MemoriesPanel"
|
|
711
826
|
const pascal = shortName.replace(/(^|-)([a-z])/g, (_: string, __: string, c: string) => c.toUpperCase());
|
|
712
|
-
if (mod[pascal]
|
|
713
|
-
return mod[pascal] as RegisteredUIPlugin;
|
|
714
|
-
}
|
|
827
|
+
if (accept(mod[pascal])) return mod[pascal];
|
|
715
828
|
|
|
716
829
|
// 3. Default export (single-plugin bundles)
|
|
717
|
-
if (mod.default
|
|
718
|
-
return mod.default as RegisteredUIPlugin;
|
|
719
|
-
}
|
|
830
|
+
if (accept(mod.default)) return mod.default;
|
|
720
831
|
|
|
721
832
|
// 4. Match by .id field (last resort — can be unreliable in combined bundles)
|
|
722
833
|
for (const key of Object.keys(mod)) {
|
|
723
834
|
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
|
-
}
|
|
835
|
+
if (val && (val.id === pluginId || val.id === shortName) && accept(val)) return val;
|
|
732
836
|
}
|
|
733
837
|
|
|
734
838
|
return null;
|
package/src/bundles/storage.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
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';
|
|
8
9
|
|
|
9
10
|
export interface BundleCacheEntry {
|
|
10
11
|
localPath: string;
|
|
@@ -27,6 +28,24 @@ export interface BundleCacheEntry {
|
|
|
27
28
|
* untrusted. Never read a missing tenant as "mine".
|
|
28
29
|
*/
|
|
29
30
|
tenant?: string;
|
|
31
|
+
/**
|
|
32
|
+
* The trusted declaration these bytes' device tools were last registered
|
|
33
|
+
* under: the pluginId and grants resolved from the server manifest that named
|
|
34
|
+
* this bundle (resolvePluginGrants). Recorded so the disk-cache recovery path —
|
|
35
|
+
* which runs exactly when there is NO manifest — re-registers with the same
|
|
36
|
+
* authority instead of the bundle's own claim.
|
|
37
|
+
*
|
|
38
|
+
* Absent → no trusted declaration is known for these bytes, so recovery skips
|
|
39
|
+
* the entry (fail closed) and the catalog path registers it once the owning
|
|
40
|
+
* connector answers again.
|
|
41
|
+
*/
|
|
42
|
+
trusted?: TrustedToolDeclaration;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Who a bundle's device tools belong to and what they are granted — always
|
|
46
|
+
* from the manifest, never from the bundle. */
|
|
47
|
+
export interface TrustedToolDeclaration extends PluginGrants {
|
|
48
|
+
pluginId: string;
|
|
30
49
|
}
|
|
31
50
|
|
|
32
51
|
/**
|
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]);
|
package/src/hooks/usePlugin.ts
CHANGED
|
@@ -13,13 +13,18 @@
|
|
|
13
13
|
import { useState, useEffect } from 'react';
|
|
14
14
|
import { bundleManager } from '../bundles/bundleManager';
|
|
15
15
|
import { nativePluginRegistry } from '../host';
|
|
16
|
-
import
|
|
16
|
+
import { resolvePluginGrants } from '../pluginRuntime';
|
|
17
|
+
import type { NativeCapabilities, RegisteredUIPlugin } from '../sdk/types';
|
|
18
|
+
import type { DeviceBridgeDomain } from '../sdk/device-state';
|
|
17
19
|
|
|
18
20
|
interface PluginManifestLike {
|
|
19
21
|
version?: string;
|
|
20
22
|
name?: string;
|
|
21
23
|
render?: any;
|
|
22
24
|
_connectorId?: string;
|
|
25
|
+
capabilities?: Record<string, unknown>;
|
|
26
|
+
native?: NativeCapabilities;
|
|
27
|
+
stateDomains?: DeviceBridgeDomain[];
|
|
23
28
|
}
|
|
24
29
|
|
|
25
30
|
interface UsePluginResult {
|
|
@@ -102,6 +107,11 @@ export function usePlugin(pluginId: string, manifest: PluginManifestLike): UsePl
|
|
|
102
107
|
const bundleHash = extractBundleHash(manifest);
|
|
103
108
|
const componentId = extractComponentId(manifest);
|
|
104
109
|
const lookupId = componentId || pluginId;
|
|
110
|
+
// The manifest's grants — the authority for the bundle's device tools, the
|
|
111
|
+
// same one the renderer gates the component with. Keyed by value so a fresh
|
|
112
|
+
// manifest object with the same grants does not reload the plugin.
|
|
113
|
+
const grants = resolvePluginGrants({ pluginId, manifest });
|
|
114
|
+
const grantsKey = JSON.stringify(grants);
|
|
105
115
|
|
|
106
116
|
if (ignoredUrl) {
|
|
107
117
|
console.warn(`[usePlugin] ${pluginId}: IGNORED connector-injected bundleUrl="${ignoredUrl}". The host derives bundle URLs from pluginId via the canonical route.`);
|
|
@@ -132,7 +142,7 @@ export function usePlugin(pluginId: string, manifest: PluginManifestLike): UsePl
|
|
|
132
142
|
// Path 2: Remote bundle (via global BundleManager)
|
|
133
143
|
if (bundleUrl) {
|
|
134
144
|
try {
|
|
135
|
-
const component = await bundleManager.load(pluginId, bundleUrl, version, bundleHash);
|
|
145
|
+
const component = await bundleManager.load(pluginId, bundleUrl, version, bundleHash, grants);
|
|
136
146
|
if (cancelled) return;
|
|
137
147
|
setPlugin(component);
|
|
138
148
|
setLoading(false);
|
|
@@ -179,7 +189,7 @@ export function usePlugin(pluginId: string, manifest: PluginManifestLike): UsePl
|
|
|
179
189
|
})();
|
|
180
190
|
|
|
181
191
|
return () => { cancelled = true; };
|
|
182
|
-
}, [pluginId, lookupId, bundleUrl, bundleHash, version]);
|
|
192
|
+
}, [pluginId, lookupId, bundleUrl, bundleHash, version, grantsKey]);
|
|
183
193
|
|
|
184
194
|
return { plugin, loading, error, bundleMissing };
|
|
185
195
|
}
|
package/src/host.ts
CHANGED
|
@@ -50,6 +50,7 @@ const noopNative: PluginNative = {
|
|
|
50
50
|
nfc: unavailable('nfc'),
|
|
51
51
|
bluetooth: unavailable('bluetooth'),
|
|
52
52
|
vision: unavailable('vision'),
|
|
53
|
+
maps: unavailable('maps'),
|
|
53
54
|
};
|
|
54
55
|
|
|
55
56
|
let _pluginNative: PluginNative = noopNative;
|
|
@@ -129,6 +130,7 @@ export function gatePluginNative(
|
|
|
129
130
|
// used to be, which crashed with "cannot read properties of undefined".
|
|
130
131
|
bluetooth: caps.bluetooth ? native.bluetooth : blocked('bluetooth'),
|
|
131
132
|
vision: caps.vision ? native.vision : blocked('vision'),
|
|
133
|
+
maps: caps.maps ? native.maps : blocked('maps'),
|
|
132
134
|
// What this plugin DECLARED, carried on the object it already receives.
|
|
133
135
|
//
|
|
134
136
|
// It is how a COMPONENT can be capability-gated: <CameraPreview> is handed
|
package/src/index.ts
CHANGED
|
@@ -35,6 +35,11 @@ export type {
|
|
|
35
35
|
SpatialSnapshot,
|
|
36
36
|
SpatialPoint,
|
|
37
37
|
SpatialIntent,
|
|
38
|
+
SpatialEvent,
|
|
39
|
+
SpatialStateEvent,
|
|
40
|
+
StateEvent,
|
|
41
|
+
StateListener,
|
|
42
|
+
Subscription,
|
|
38
43
|
ActivitySnapshot,
|
|
39
44
|
EnvironmentSnapshot,
|
|
40
45
|
InteractionSnapshot,
|
|
@@ -86,11 +91,11 @@ export {
|
|
|
86
91
|
registerBundleModules,
|
|
87
92
|
} from './host';
|
|
88
93
|
|
|
89
|
-
// The ONE runtime contract
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
export { resolvePluginRuntimeProps } from './pluginRuntime';
|
|
93
|
-
export type { PluginRuntimeProps, PluginRuntimeSource } from './pluginRuntime';
|
|
94
|
+
// The ONE runtime contract that gates a plugin's device surface (device/native +
|
|
95
|
+
// deviceState) — every renderer AND its device tools resolve grants through it,
|
|
96
|
+
// so a plugin gets identical access on every surface and in its tools.
|
|
97
|
+
export { resolvePluginRuntimeProps, resolvePluginGrants } from './pluginRuntime';
|
|
98
|
+
export type { PluginRuntimeProps, PluginRuntimeSource, PluginGrants } from './pluginRuntime';
|
|
94
99
|
|
|
95
100
|
// Device tools (plugin `tools/` sections — registered at bundle load)
|
|
96
101
|
export {
|
|
@@ -121,7 +126,7 @@ export {
|
|
|
121
126
|
export { bundleManager } from './bundles/bundleManager';
|
|
122
127
|
export type { LoadedBundleInfo, LoadedBundleSource } from './bundles/bundleManager';
|
|
123
128
|
export { setBundleStorage, MemoryBundleStorage } from './bundles/storage';
|
|
124
|
-
export type { BundleStorage, BundleCacheEntry } from './bundles/storage';
|
|
129
|
+
export type { BundleStorage, BundleCacheEntry, TrustedToolDeclaration } from './bundles/storage';
|
|
125
130
|
|
|
126
131
|
// Bridge
|
|
127
132
|
export { useNativeBridge, createNativeBridge } from './bridge/native';
|
package/src/pluginRuntime.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The ONE runtime contract that turns a plugin's declarations into its gated
|
|
3
3
|
* device surface — `props.device` / `props.native` (L2) and `props.deviceState`
|
|
4
|
-
* (L3) — for EVERY renderer, on EVERY surface.
|
|
4
|
+
* (L3) — for EVERY renderer, on EVERY surface, and for its device tools.
|
|
5
5
|
*
|
|
6
6
|
* WHY THIS EXISTS. Each renderer used to resolve this itself: the embedded route
|
|
7
7
|
* gated both `native` and `deviceState`; the drawer/fullscreen renderer gated
|
|
@@ -29,11 +29,6 @@ export interface PluginRuntimeProps {
|
|
|
29
29
|
voice: VoiceAPI;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
/**
|
|
33
|
-
* Where a plugin's declarations come from. TRUSTED source resolution lives here,
|
|
34
|
-
* not in each caller: the server `manifest` wins, the loaded bundle's own default
|
|
35
|
-
* export is the fallback.
|
|
36
|
-
*/
|
|
37
32
|
/** One declaration source — either the server manifest or the registry entry. */
|
|
38
33
|
interface PluginDeclaration {
|
|
39
34
|
capabilities?: NativeCapabilities | Record<string, unknown>;
|
|
@@ -68,9 +63,18 @@ function mergeCaps(decl: PluginDeclaration | null | undefined): NativeCapabiliti
|
|
|
68
63
|
return { ...(decl.native as any), ...(decl.capabilities as any) } as NativeCapabilities;
|
|
69
64
|
}
|
|
70
65
|
|
|
66
|
+
/** What a plugin is GRANTED — resolved from its one trusted source. The same
|
|
67
|
+
* value gates its rendered surface and its device tools (the tool registry
|
|
68
|
+
* receives it through bundleManager.load/loadTools). */
|
|
69
|
+
export interface PluginGrants {
|
|
70
|
+
capabilities?: NativeCapabilities;
|
|
71
|
+
stateDomains?: DeviceBridgeDomain[];
|
|
72
|
+
}
|
|
73
|
+
|
|
71
74
|
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
75
|
+
* Resolve a plugin's grants — the SINGLE place this happens, for every rendered
|
|
76
|
+
* surface AND for the device tools its bundle registers, so a plugin gets
|
|
77
|
+
* identical access through either.
|
|
74
78
|
*
|
|
75
79
|
* TRUST BOUNDARY. Exactly ONE source is authoritative: the server `manifest` for
|
|
76
80
|
* a remote plugin, the registry `plugin` for a host-owned one. They are NEVER
|
|
@@ -80,10 +84,17 @@ function mergeCaps(decl: PluginDeclaration | null | undefined): NativeCapabiliti
|
|
|
80
84
|
* not carry `stateDomains` for a remote plugin, the fix is Core passing it, not
|
|
81
85
|
* trusting the bundle: Core already holds the solution's declaration.)
|
|
82
86
|
*/
|
|
83
|
-
export function
|
|
87
|
+
export function resolvePluginGrants(src: PluginRuntimeSource): PluginGrants {
|
|
84
88
|
const trusted = src.manifest ?? src.plugin ?? null;
|
|
85
|
-
|
|
86
|
-
const stateDomains = trusted?.stateDomains;
|
|
89
|
+
// Copied, so the grant cannot change after the fact by mutating the source.
|
|
90
|
+
const stateDomains = trusted?.stateDomains ? [...trusted.stateDomains] : undefined;
|
|
91
|
+
return { capabilities: mergeCaps(trusted), stateDomains };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Turn a plugin's grants into the gated { device, native, deviceState, voice } it
|
|
95
|
+
* receives on every rendered surface. */
|
|
96
|
+
export function resolvePluginRuntimeProps(src: PluginRuntimeSource): PluginRuntimeProps {
|
|
97
|
+
const { capabilities, stateDomains } = resolvePluginGrants(src);
|
|
87
98
|
const native = gatePluginNative(getPluginNative(), capabilities, src.pluginId);
|
|
88
99
|
const deviceState = gateDeviceState(stateDomains);
|
|
89
100
|
// Voice is a capability flag, gated the same way — the TRUSTED declaration
|