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