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