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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,6 +7,23 @@ var __export = (target, all) => {
7
7
  // src/api/plugins.ts
8
8
  import { apiFetch } from "@ateam-ai-mobile/core";
9
9
  import { getCoreApiUrl } from "@ateam-ai-mobile/core";
10
+
11
+ // src/bundles/bundleUrl.ts
12
+ function pluginBundleUrl(pluginId) {
13
+ return `/api/ui-plugins/${encodeURIComponent(pluginId)}/bundle.js`;
14
+ }
15
+ function isPluginBundleUrl(url, pluginId) {
16
+ if (typeof url !== "string") return false;
17
+ const canonical = pluginBundleUrl(pluginId);
18
+ if (url === canonical) return true;
19
+ try {
20
+ return decodeURIComponent(url) === decodeURIComponent(canonical);
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ // src/api/plugins.ts
10
27
  function supportsNative(manifest) {
11
28
  const mode = manifest.render?.mode;
12
29
  return mode === "react-native" || mode === "adaptive";
@@ -17,12 +34,9 @@ function getNativeComponentId(manifest) {
17
34
  return null;
18
35
  }
19
36
  function getNativeBundleUrl(manifest) {
20
- const direct = manifest.bundleUrl;
21
- if (direct) return direct;
22
37
  const render = manifest.render;
23
- if (render.mode === "react-native") return render.bundleUrl || null;
24
- if (render.mode === "adaptive") return render.reactNative?.bundleUrl || null;
25
- return null;
38
+ const declared = manifest.bundleUrl || (render?.mode === "react-native" ? render.bundleUrl : null) || (render?.mode === "adaptive" ? render.reactNative?.bundleUrl : null);
39
+ return declared && manifest.id ? pluginBundleUrl(manifest.id) : null;
26
40
  }
27
41
  function getNativeBundleHash(manifest) {
28
42
  const direct = manifest.bundleHash;
@@ -155,6 +169,8 @@ __export(sdk_exports, {
155
169
  TextInput: () => TextInput,
156
170
  TouchableOpacity: () => TouchableOpacity,
157
171
  View: () => View,
172
+ explainCapability: () => explainCapability,
173
+ isActionable: () => isActionable,
158
174
  register: () => register,
159
175
  registerService: () => registerService,
160
176
  registerTools: () => registerTools,
@@ -197,6 +213,60 @@ function registerService(id, config) {
197
213
  // src/sdk/voice.ts
198
214
  var PLATFORM_VOICE_CALLER = /* @__PURE__ */ Symbol("adas.voice.platform");
199
215
 
216
+ // src/sdk/device-state.ts
217
+ var device_state_exports = {};
218
+ __export(device_state_exports, {
219
+ DeviceBridgeError: () => DeviceBridgeError,
220
+ explainCapability: () => explainCapability,
221
+ isActionable: () => isActionable,
222
+ neverObserved: () => neverObserved
223
+ });
224
+ var DeviceBridgeError = class extends Error {
225
+ constructor(code, message, opts) {
226
+ super(message);
227
+ this.name = "DeviceBridgeError";
228
+ this.code = code;
229
+ this.domain = opts?.domain;
230
+ this.recoverable = opts?.recoverable;
231
+ }
232
+ };
233
+ var CONFIDENCE_ORDER = {
234
+ unknown: 0,
235
+ low: 1,
236
+ medium: 2,
237
+ high: 3
238
+ };
239
+ function isActionable(quality, budget) {
240
+ const { freshness, confidence } = quality;
241
+ if (freshness.state === "unknown" || freshness.state === "stale") return false;
242
+ if (freshness.ageMs === null || freshness.ageMs > budget.maxAgeMs) return false;
243
+ if (budget.minConfidence) {
244
+ const have = CONFIDENCE_ORDER[confidence?.level ?? "unknown"];
245
+ if (have < CONFIDENCE_ORDER[budget.minConfidence]) return false;
246
+ }
247
+ return true;
248
+ }
249
+ function neverObserved() {
250
+ return { state: "unknown", observedAt: null, ageMs: null };
251
+ }
252
+ function explainCapability(status) {
253
+ if (status.support === "unsupported") return "this device does not have it";
254
+ if (status.permission === "denied") return "permission was denied";
255
+ if (status.permission === "restricted") return "permission is restricted";
256
+ if (status.permission === "notDetermined") return "permission has not been asked for yet";
257
+ if (status.permission === "limited") return "only limited access was granted";
258
+ if (status.restriction === "policyRestricted") return "this plugin did not declare it";
259
+ if (status.restriction === "backgroundRestricted") return "the app is in the background";
260
+ if (status.restriction === "lowPowerRestricted") return "the device is in low-power mode";
261
+ if (status.availability === "busy") return "it is in use by something else";
262
+ if (status.availability === "temporarilyUnavailable") return "it is temporarily unavailable";
263
+ if (status.availability === "unavailable") return "it is unavailable";
264
+ if (status.support === "unknown" || status.availability === "unknown") {
265
+ return "its state could not be determined";
266
+ }
267
+ return null;
268
+ }
269
+
200
270
  // src/sdk/primitives.ts
201
271
  import {
202
272
  View,
@@ -511,7 +581,8 @@ var noopNative = {
511
581
  scanner: unavailable("scanner"),
512
582
  nfc: unavailable("nfc"),
513
583
  bluetooth: unavailable("bluetooth"),
514
- vision: unavailable("vision")
584
+ vision: unavailable("vision"),
585
+ maps: unavailable("maps")
515
586
  };
516
587
  var _pluginNative = noopNative;
517
588
  function setPluginNativeProvider(native) {
@@ -553,7 +624,8 @@ function gatePluginNative(native, declared, pluginId) {
553
624
  // every method refuses by name. Both are better than the `undefined` this
554
625
  // used to be, which crashed with "cannot read properties of undefined".
555
626
  bluetooth: caps.bluetooth ? native.bluetooth : blocked("bluetooth"),
556
- vision: caps.vision ? native.vision : blocked("vision")
627
+ vision: caps.vision ? native.vision : blocked("vision"),
628
+ maps: caps.maps ? native.maps : blocked("maps")
557
629
  // What this plugin DECLARED, carried on the object it already receives.
558
630
  //
559
631
  // It is how a COMPONENT can be capability-gated: <CameraPreview> is handed
@@ -1561,6 +1633,14 @@ function SurfaceDispatcher() {
1561
1633
  ] });
1562
1634
  }
1563
1635
 
1636
+ // src/bundles/bundleModuleNames.ts
1637
+ var PLUGIN_SDK_MODULE = "@adas/plugin-sdk";
1638
+ function sdkModuleRefusal(name) {
1639
+ if (name === PLUGIN_SDK_MODULE) return null;
1640
+ if (!name.startsWith("@ateam-ai-mobile/") && !name.startsWith("@adas/")) return null;
1641
+ return `a plugin bundle required '${name}', which does not exist inside a bundle. Import from '${PLUGIN_SDK_MODULE}' instead \u2014 it is the only SDK module a bundle can require (values and types alike), and the one to keep external when building. '@ateam-ai-mobile/*' are the host app's packages, not the plugin's.`;
1642
+ }
1643
+
1564
1644
  // src/utils/sha256.ts
1565
1645
  var K = new Uint32Array([
1566
1646
  1116352408,
@@ -1715,6 +1795,22 @@ function selectOwnedCacheEntries(entries, tenant) {
1715
1795
  if (!tenant) return [];
1716
1796
  return (entries || []).filter((e) => !!e && e.tenant === tenant);
1717
1797
  }
1798
+ async function loadCacheIndex(storage = getBundleStorage()) {
1799
+ const index = await storage.loadIndex() || {};
1800
+ for (const [key, entry] of Object.entries(index)) {
1801
+ const pluginId = entry?.trusted?.pluginId;
1802
+ if (!pluginId) continue;
1803
+ const canonical = pluginBundleUrl(pluginId);
1804
+ if (key === canonical) continue;
1805
+ if (!isPluginBundleUrl(key, pluginId) || !isPluginBundleUrl(entry.bundleUrl, pluginId)) continue;
1806
+ delete index[key];
1807
+ const held = index[canonical];
1808
+ if (!held || (entry.timestamp ?? 0) > (held.timestamp ?? 0)) {
1809
+ index[canonical] = { ...entry, bundleUrl: canonical };
1810
+ }
1811
+ }
1812
+ return index;
1813
+ }
1718
1814
  var MemoryBundleStorage = class {
1719
1815
  constructor() {
1720
1816
  this.index = {};
@@ -2104,6 +2200,19 @@ function gateDeviceState(declared) {
2104
2200
  }
2105
2201
  return out;
2106
2202
  }
2203
+ function withoutSubscribe(state) {
2204
+ if (!state) return state;
2205
+ const out = {};
2206
+ for (const domain of ALL_DOMAINS) {
2207
+ const d = state[domain];
2208
+ out[domain] = d && Object.assign(Object.create(d), {
2209
+ subscribe() {
2210
+ 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.");
2211
+ }
2212
+ });
2213
+ }
2214
+ return out;
2215
+ }
2107
2216
  function implementedStateDomains() {
2108
2217
  return ALL_DOMAINS.filter((d) => !!_provider[d]);
2109
2218
  }
@@ -2149,8 +2258,9 @@ function unregisterDeviceTools(pluginId) {
2149
2258
  if (entry.pluginId === pluginId) registry.delete(name);
2150
2259
  }
2151
2260
  }
2152
- function hasDeviceTool(name) {
2153
- return registry.has(name);
2261
+ function hasDeviceTool(name, pluginId) {
2262
+ const entry = registry.get(name);
2263
+ return !!entry && (pluginId === void 0 || entry.pluginId === pluginId);
2154
2264
  }
2155
2265
  function listDeviceTools() {
2156
2266
  return Array.from(registry.entries()).map(([name, { pluginId, tool }]) => ({
@@ -2169,8 +2279,11 @@ async function callDeviceTool(name, args = {}, opts = {}) {
2169
2279
  let timer;
2170
2280
  try {
2171
2281
  const build = contextProvider || defaultContext;
2282
+ const built = build(entry.pluginId, entry.capabilities, entry.stateDomains);
2172
2283
  const ctx = {
2173
- ...build(entry.pluginId, entry.capabilities, entry.stateDomains),
2284
+ ...built,
2285
+ // Whoever built the context, a call cannot own a subscription.
2286
+ deviceState: withoutSubscribe(built.deviceState),
2174
2287
  signal: opts.signal
2175
2288
  };
2176
2289
  const result = await Promise.race([
@@ -2196,6 +2309,9 @@ function clearDeviceTools() {
2196
2309
  }
2197
2310
 
2198
2311
  // src/bundles/bundleManager.ts
2312
+ function bundleKey(pluginId, bundleUrl) {
2313
+ return isPluginBundleUrl(bundleUrl, pluginId) ? pluginBundleUrl(pluginId) : bundleUrl;
2314
+ }
2199
2315
  var ReactShim = {
2200
2316
  __esModule: true,
2201
2317
  default: React2,
@@ -2241,13 +2357,17 @@ var PluginSDKShim = Object.freeze({
2241
2357
  var requireShim = (name) => {
2242
2358
  if (name === "react") return ReactShim;
2243
2359
  if (name === "react-native") return ReactNative;
2244
- if (name === "@adas/plugin-sdk") return PluginSDKShim;
2360
+ if (name === PLUGIN_SDK_MODULE) return PluginSDKShim;
2361
+ const refusal = sdkModuleRefusal(name);
2362
+ if (refusal) throw new Error(`[BundleManager] ${refusal}`);
2245
2363
  const hostModule = resolveBundleModule(name);
2246
2364
  if (hostModule !== void 0) return hostModule;
2247
2365
  console.warn(`[BundleManager] Unknown module: ${name} \u2014 register it via registerBundleModules()`);
2248
2366
  return {};
2249
2367
  };
2250
2368
  var CACHE_TTL = 7 * 24 * 60 * 60 * 1e3;
2369
+ var hasComponent = (val) => !!val && typeof val === "object" && typeof val.Component === "function";
2370
+ var hasTools = (val) => !!val && typeof val === "object" && Array.isArray(val.tools) && val.tools.length > 0;
2251
2371
  var BundleManager = class {
2252
2372
  constructor() {
2253
2373
  /** In-memory module cache: bundleUrl → evaluated module.exports. Holds ONE
@@ -2278,6 +2398,9 @@ var BundleManager = class {
2278
2398
  * of the previous tenant's tools.
2279
2399
  */
2280
2400
  this.registeredToolPluginIds = /* @__PURE__ */ new Set();
2401
+ /** bundleUrl → the trusted declaration last recorded on its disk-cache entry
2402
+ * this session, so a memory hit with unchanged grants skips the index I/O. */
2403
+ this.recordedDeclarations = /* @__PURE__ */ new Map();
2281
2404
  /**
2282
2405
  * Enforce the in-memory tenant boundary. Call at the top of every public load
2283
2406
  * path, BEFORE any cache read.
@@ -2323,6 +2446,7 @@ var BundleManager = class {
2323
2446
  }
2324
2447
  }
2325
2448
  this.registeredToolPluginIds.clear();
2449
+ this.recordedDeclarations.clear();
2326
2450
  this.modules.clear();
2327
2451
  this.provenance.clear();
2328
2452
  this.inflight.clear();
@@ -2334,13 +2458,15 @@ var BundleManager = class {
2334
2458
  isStale(epoch, tenant) {
2335
2459
  return this.epoch !== epoch || getTenant2() !== tenant;
2336
2460
  }
2337
- /** Flip an existing provenance record to source:'memory' — a later serve of an
2338
- * already-evaluated bundle came from the in-memory cache, not a fresh
2339
- * download/disk read. No-op if nothing was recorded yet. */
2340
- markMemoryHit(bundleUrl) {
2461
+ /** Record a memory serve: the running bytes are the already-evaluated module,
2462
+ * and `expected` is refreshed from the manifest hash the caller JUST passed —
2463
+ * so after a redeploy the record reads hash !== expected (STALE) instead of
2464
+ * the old in-sync pair. The ONE place both memory paths (load()'s early return
2465
+ * and loadModule's) record reuse. No-op if nothing was recorded yet. */
2466
+ markMemoryHit(bundleUrl, expectedHash) {
2341
2467
  const prev = this.provenance.get(bundleUrl);
2342
- if (prev && prev.source !== "memory") {
2343
- this.provenance.set(bundleUrl, { ...prev, source: "memory" });
2468
+ if (prev) {
2469
+ this.provenance.set(bundleUrl, { ...prev, source: "memory", expected: expectedHash?.toLowerCase() ?? prev.expected });
2344
2470
  }
2345
2471
  }
2346
2472
  // ── Public API ──────────────────────────────────────────────
@@ -2352,13 +2478,22 @@ var BundleManager = class {
2352
2478
  * provided for any bundle the user has not previously verified — without
2353
2479
  * it, a tampered bundle is `eval`'d as JS with full RN runtime + every
2354
2480
  * native capability the manifest declares.
2481
+ *
2482
+ * `grants` is what the MANIFEST grants this plugin (resolvePluginGrants) — the
2483
+ * only authority its device tools are registered under. Omitted (e.g. a
2484
+ * prewarm from the slim plugin list) → the bundle is loaded but its tools are
2485
+ * NOT registered: without the manifest there is nothing trusted to gate them
2486
+ * on, and registering with nothing would downgrade a registration the
2487
+ * manifest-driven tool loader already made.
2355
2488
  */
2356
- async load(pluginId, bundleUrl, version, expectedHash) {
2489
+ async load(pluginId, bundleUrl, version, expectedHash, grants) {
2490
+ bundleUrl = bundleKey(pluginId, bundleUrl);
2357
2491
  const tenant = this.requireTenant();
2358
2492
  const epoch = this.epoch;
2359
2493
  const cached = this.getComponentFromMemory(pluginId, bundleUrl);
2360
2494
  if (cached) {
2361
- this.markMemoryHit(bundleUrl);
2495
+ this.markMemoryHit(bundleUrl, expectedHash);
2496
+ if (grants) await this.commitTools(pluginId, bundleUrl, grants, tenant, epoch);
2362
2497
  return cached;
2363
2498
  }
2364
2499
  let modulePromise = this.inflight.get(bundleUrl);
@@ -2375,6 +2510,7 @@ var BundleManager = class {
2375
2510
  if (this.isStale(epoch, tenant)) {
2376
2511
  throw new Error("[BundleManager] tenant changed while loading \u2014 discarding (fail closed)");
2377
2512
  }
2513
+ if (grants) await this.commitTools(pluginId, bundleUrl, grants, tenant, epoch);
2378
2514
  const component = this.getComponentFromMemory(pluginId, bundleUrl);
2379
2515
  if (!component) {
2380
2516
  throw new Error(
@@ -2389,8 +2525,15 @@ var BundleManager = class {
2389
2525
  * Same download, same integrity check, same evaluation as `load()`; the only
2390
2526
  * difference is that a bundle exporting tools and no `Component` is a success
2391
2527
  * here rather than "no component found". Returns the tool names registered.
2528
+ *
2529
+ * `grants` is REQUIRED: the tools are registered under `pluginId` with exactly
2530
+ * what the manifest grants (see load()), never what the bundle claims.
2392
2531
  */
2393
- async loadTools(pluginId, bundleUrl, version, expectedHash) {
2532
+ async loadTools(pluginId, bundleUrl, version, expectedHash, grants) {
2533
+ if (!grants) {
2534
+ throw new Error(`[BundleManager] loadTools(${pluginId}) needs the manifest's grants (resolvePluginGrants) \u2014 refusing to register tools on the bundle's own claim`);
2535
+ }
2536
+ bundleUrl = bundleKey(pluginId, bundleUrl);
2394
2537
  const tenant = this.requireTenant();
2395
2538
  const epoch = this.epoch;
2396
2539
  let modulePromise = this.inflight.get(bundleUrl);
@@ -2407,6 +2550,7 @@ var BundleManager = class {
2407
2550
  if (this.isStale(epoch, tenant)) {
2408
2551
  throw new Error("[BundleManager] tenant changed while loading tools \u2014 discarding (fail closed)");
2409
2552
  }
2553
+ await this.commitTools(pluginId, bundleUrl, grants, tenant, epoch);
2410
2554
  const names = listDeviceTools().filter((t) => t.pluginId === pluginId).map((t) => t.name);
2411
2555
  if (names.length === 0) {
2412
2556
  console.warn(
@@ -2432,7 +2576,7 @@ var BundleManager = class {
2432
2576
  const it = queue.shift();
2433
2577
  if (!it) return;
2434
2578
  try {
2435
- await this.load(it.pluginId, it.bundleUrl, it.version, it.bundleHash);
2579
+ await this.load(it.pluginId, it.bundleUrl, it.version, it.bundleHash, it.grants);
2436
2580
  okCount++;
2437
2581
  } catch (err) {
2438
2582
  failCount++;
@@ -2460,11 +2604,12 @@ var BundleManager = class {
2460
2604
  */
2461
2605
  async clearPlugin(pluginId) {
2462
2606
  const storage = getBundleStorage();
2463
- const index = await storage.loadIndex();
2464
- const entry = index[pluginId];
2607
+ const index = await loadCacheIndex(storage);
2608
+ const bundleUrl = pluginBundleUrl(pluginId);
2609
+ const entry = index[bundleUrl];
2465
2610
  if (entry) {
2466
- this.modules.delete(entry.bundleUrl);
2467
- delete index[pluginId];
2611
+ this.modules.delete(bundleUrl);
2612
+ delete index[bundleUrl];
2468
2613
  await storage.saveIndex(index);
2469
2614
  try {
2470
2615
  await storage.deleteBundle(entry.localPath);
@@ -2489,12 +2634,11 @@ var BundleManager = class {
2489
2634
  throw new Error("[BundleManager] tenant changed before load started \u2014 discarding (fail closed)");
2490
2635
  }
2491
2636
  if (this.modules.has(bundleUrl)) {
2492
- const prev = this.provenance.get(bundleUrl);
2493
- if (prev) this.provenance.set(bundleUrl, { ...prev, source: "memory", expected: expectedHash?.toLowerCase() ?? prev.expected });
2637
+ this.markMemoryHit(bundleUrl, expectedHash);
2494
2638
  return;
2495
2639
  }
2496
2640
  const storage = getBundleStorage();
2497
- const index = await storage.loadIndex();
2641
+ const index = await loadCacheIndex(storage);
2498
2642
  const expectedLc = expectedHash ? expectedHash.toLowerCase() : null;
2499
2643
  let content = null;
2500
2644
  let verifiedHash = null;
@@ -2557,30 +2701,90 @@ var BundleManager = class {
2557
2701
  console.log(
2558
2702
  `[BundleManager] RUNNING ${bundleUrl} v${version} sha=${(verifiedHash || "none").slice(0, 12)} (${servedFrom}${expectedLc && verifiedHash && expectedLc !== verifiedHash ? `, EXPECTED ${expectedLc.slice(0, 12)} \u2014 STALE` : ""})`
2559
2703
  );
2560
- this.registerToolsFromModule(mod, bundleUrl);
2561
2704
  }
2562
2705
  /**
2563
- * Pick up every `tools` section in an evaluated bundle and put it in the
2564
- * device tool registry. Runs for UI, hybrid and tools-only bundles alike: a
2565
- * plugin that ships both gets its tools registered without its UI mounting.
2706
+ * Register THIS plugin's device tools under the TRUSTED declaration, then
2707
+ * record that declaration on the disk-cache entry. Runs on every trusted load
2708
+ * — memory hit included — so the grants in force are always the latest the
2709
+ * manifest gave (a revoked grant stops applying on the next load, not the next
2710
+ * app start). Callers run it only after their stale check.
2711
+ */
2712
+ async commitTools(pluginId, bundleUrl, grants, tenant, epoch) {
2713
+ const declaration = {
2714
+ pluginId,
2715
+ capabilities: grants.capabilities,
2716
+ stateDomains: grants.stateDomains
2717
+ };
2718
+ this.registerToolsFromModule(this.modules.get(bundleUrl), declaration);
2719
+ await this.recordTrustedDeclaration(bundleUrl, declaration, tenant, epoch);
2720
+ }
2721
+ /**
2722
+ * Put the plugin's own `tools` section in the device tool registry — under the
2723
+ * pluginId and grants the MANIFEST resolved, never the bundle's own `id`,
2724
+ * `capabilities` or `stateDomains`. A bundle is code the manifest authorised
2725
+ * by hash; what it may touch is the manifest's to say, same as its rendered
2726
+ * surface (resolvePluginRuntimeProps). Its own `id` would also let it register
2727
+ * as another plugin — including the host's platform tools, which a same-id
2728
+ * re-registration replaces.
2729
+ *
2730
+ * Only this plugin's export (the same lookup the component uses) is
2731
+ * registered. Another export's tools in the same bundle belong to another
2732
+ * plugin and register when THAT plugin's manifest loads it.
2566
2733
  */
2567
- registerToolsFromModule(mod, bundleUrl) {
2734
+ registerToolsFromModule(mod, { pluginId, capabilities, stateDomains }) {
2568
2735
  if (!mod) return;
2569
- for (const key of Object.keys(mod)) {
2570
- const val = mod[key];
2571
- if (!val || typeof val !== "object" || !Array.isArray(val.tools) || val.tools.length === 0) {
2572
- continue;
2573
- }
2574
- const pluginId = typeof val.id === "string" ? val.id : `${bundleUrl}#${key}`;
2575
- try {
2576
- registerDeviceTools(pluginId, val.tools, val.capabilities, val.stateDomains);
2577
- this.registeredToolPluginIds.add(pluginId);
2578
- } catch (err) {
2579
- console.warn(
2580
- `[BundleManager] tool registration failed for ${pluginId}:`,
2581
- err instanceof Error ? err.message : String(err)
2582
- );
2736
+ const own = this.findPluginExport(mod, pluginId, hasTools);
2737
+ const others = Object.keys(mod).filter((k) => hasTools(mod[k]) && mod[k] !== own);
2738
+ if (others.length > 0) {
2739
+ console.warn(
2740
+ `[BundleManager] ${pluginId}: not registering tools from export(s) ${others.join(", ")} \u2014 they are not this plugin's, and only their own manifest can grant them`
2741
+ );
2742
+ }
2743
+ if (!own) return;
2744
+ const ungranted = [
2745
+ ...(Array.isArray(own.stateDomains) ? own.stateDomains : []).filter((d) => !stateDomains?.includes(d)),
2746
+ ...Object.keys(own.capabilities || {}).filter((k) => own.capabilities[k] === true && !capabilities?.[k])
2747
+ ];
2748
+ if (ungranted.length > 0) {
2749
+ console.warn(
2750
+ `[BundleManager] ${pluginId}: bundle declares ${ungranted.join(", ")} but the manifest does not grant it \u2014 denied`
2751
+ );
2752
+ }
2753
+ try {
2754
+ registerDeviceTools(pluginId, own.tools, capabilities, stateDomains);
2755
+ this.registeredToolPluginIds.add(pluginId);
2756
+ } catch (err) {
2757
+ console.warn(
2758
+ `[BundleManager] tool registration failed for ${pluginId}:`,
2759
+ err instanceof Error ? err.message : String(err)
2760
+ );
2761
+ }
2762
+ }
2763
+ /**
2764
+ * Record the trusted declaration on this tenant's disk-cache entry for the
2765
+ * bytes RUNNING — the recovery path (tools/loader loadFromDiskCache) has no
2766
+ * manifest and re-registers with exactly this. Only onto an entry this tenant
2767
+ * owns whose hash is the running hash: the grant was issued for these bytes,
2768
+ * and those are the bytes recovery will evaluate. Best-effort — a lost write
2769
+ * leaves no `trusted`, which recovery treats as fail closed.
2770
+ */
2771
+ async recordTrustedDeclaration(bundleUrl, declaration, tenant, epoch) {
2772
+ const key = JSON.stringify(declaration);
2773
+ if (this.recordedDeclarations.get(bundleUrl) === key) return;
2774
+ try {
2775
+ const storage = getBundleStorage();
2776
+ const index = await loadCacheIndex(storage);
2777
+ const entry = index[bundleUrl];
2778
+ const running = this.provenance.get(bundleUrl)?.hash;
2779
+ if (!entry || entry.tenant !== tenant || !running || entry.bundleHash?.toLowerCase() !== running) return;
2780
+ if (this.isStale(epoch, tenant)) return;
2781
+ if (JSON.stringify(entry.trusted) !== key) {
2782
+ index[bundleUrl] = { ...entry, trusted: declaration };
2783
+ await storage.saveIndex(index);
2583
2784
  }
2785
+ this.recordedDeclarations.set(bundleUrl, key);
2786
+ } catch (err) {
2787
+ console.warn("[BundleManager] could not record trusted declaration:", err);
2584
2788
  }
2585
2789
  }
2586
2790
  /**
@@ -2663,29 +2867,26 @@ var BundleManager = class {
2663
2867
  return materialized;
2664
2868
  }
2665
2869
  // ── Private: component extraction ───────────────────────────
2870
+ /** Find a plugin component in a loaded module. */
2871
+ getComponentFromMemory(pluginId, bundleUrl) {
2872
+ return this.findPluginExport(this.modules.get(bundleUrl), pluginId, hasComponent);
2873
+ }
2666
2874
  /**
2667
- * Find a plugin component in a loaded module.
2668
- * Handles: default export, direct key match, multi-plugin .id match.
2875
+ * Find THIS plugin's export in a loaded module — the one lookup both the
2876
+ * component and the device tools use, so a plugin's UI and its tools always
2877
+ * come from the same export. `accept` says what the caller needs from it.
2878
+ * Handles: direct key match, PascalCase key, default export, multi-plugin .id match.
2669
2879
  */
2670
- getComponentFromMemory(pluginId, bundleUrl) {
2671
- const mod = this.modules.get(bundleUrl);
2880
+ findPluginExport(mod, pluginId, accept) {
2672
2881
  if (!mod) return null;
2673
2882
  const shortName = pluginId.includes(":") ? pluginId.split(":").pop() : pluginId;
2674
- if (mod[pluginId] && typeof mod[pluginId].Component === "function") {
2675
- return mod[pluginId];
2676
- }
2883
+ if (accept(mod[pluginId])) return mod[pluginId];
2677
2884
  const pascal = shortName.replace(/(^|-)([a-z])/g, (_, __, c) => c.toUpperCase());
2678
- if (mod[pascal] && typeof mod[pascal] === "object" && typeof mod[pascal].Component === "function") {
2679
- return mod[pascal];
2680
- }
2681
- if (mod.default && typeof mod.default === "object" && typeof mod.default.Component === "function") {
2682
- return mod.default;
2683
- }
2885
+ if (accept(mod[pascal])) return mod[pascal];
2886
+ if (accept(mod.default)) return mod.default;
2684
2887
  for (const key of Object.keys(mod)) {
2685
2888
  const val = mod[key];
2686
- if (val && typeof val === "object" && val.id === shortName && typeof val.Component === "function") {
2687
- return val;
2688
- }
2889
+ if (val && (val.id === pluginId || val.id === shortName) && accept(val)) return val;
2689
2890
  }
2690
2891
  return null;
2691
2892
  }
@@ -2700,6 +2901,107 @@ var BundleManager = class {
2700
2901
  };
2701
2902
  var bundleManager = new BundleManager();
2702
2903
 
2904
+ // src/voiceHost.ts
2905
+ var _provider2 = null;
2906
+ function setVoiceProvider(provider) {
2907
+ _provider2 = provider || null;
2908
+ }
2909
+ function implementedVoice() {
2910
+ return !!_provider2;
2911
+ }
2912
+ function voiceDenial(why) {
2913
+ const err = new Error(`voice: ${why}`);
2914
+ err.code = "RESTRICTED";
2915
+ return err;
2916
+ }
2917
+ function deniedVoice(why) {
2918
+ const fail = () => {
2919
+ throw voiceDenial(why);
2920
+ };
2921
+ return {
2922
+ async start() {
2923
+ return fail();
2924
+ },
2925
+ stop() {
2926
+ fail();
2927
+ },
2928
+ setMuted() {
2929
+ fail();
2930
+ },
2931
+ prompt() {
2932
+ fail();
2933
+ },
2934
+ observe() {
2935
+ fail();
2936
+ },
2937
+ getSnapshot() {
2938
+ return {
2939
+ state: "error",
2940
+ sessionId: null,
2941
+ muted: false,
2942
+ speaking: false,
2943
+ listening: false,
2944
+ userSpeaking: false,
2945
+ lastUserSpeechEndedAt: null,
2946
+ lastDeviceTool: null,
2947
+ authorizedSessionId: null,
2948
+ lastError: why
2949
+ };
2950
+ },
2951
+ subscribe() {
2952
+ return fail();
2953
+ },
2954
+ transcript() {
2955
+ return [];
2956
+ }
2957
+ };
2958
+ }
2959
+ function scopedVoice(provider, pluginId) {
2960
+ return {
2961
+ start: (opts) => provider.start(opts, pluginId),
2962
+ stop: () => provider.stop(pluginId),
2963
+ setMuted: (muted) => provider.setMuted(muted, pluginId),
2964
+ prompt: (input) => provider.prompt(input, pluginId),
2965
+ observe: (text) => provider.observe(text, pluginId),
2966
+ getSnapshot: () => provider.getSnapshot(pluginId),
2967
+ subscribe: (cb) => provider.subscribe(cb, pluginId),
2968
+ transcript: () => provider.transcript(pluginId)
2969
+ };
2970
+ }
2971
+ function gateVoice(declared, pluginId) {
2972
+ if (!declared) {
2973
+ return deniedVoice(
2974
+ "not declared in this plugin's capabilities. Add `capabilities: { voice: true }` to use it."
2975
+ );
2976
+ }
2977
+ if (!pluginId) {
2978
+ return deniedVoice(
2979
+ "no plugin identity \u2014 voice fails closed without a caller to scope the session to."
2980
+ );
2981
+ }
2982
+ return _provider2 ? scopedVoice(_provider2, pluginId) : deniedVoice(
2983
+ "no voice session in this build \u2014 the app has not mounted a VoiceSessionHost / called setVoiceProvider."
2984
+ );
2985
+ }
2986
+
2987
+ // src/pluginRuntime.ts
2988
+ function mergeCaps(decl) {
2989
+ if (!decl) return void 0;
2990
+ return { ...decl.native, ...decl.capabilities };
2991
+ }
2992
+ function resolvePluginGrants(src) {
2993
+ const trusted = src.manifest ?? src.plugin ?? null;
2994
+ const stateDomains = trusted?.stateDomains ? [...trusted.stateDomains] : void 0;
2995
+ return { capabilities: mergeCaps(trusted), stateDomains };
2996
+ }
2997
+ function resolvePluginRuntimeProps(src) {
2998
+ const { capabilities, stateDomains } = resolvePluginGrants(src);
2999
+ const native = gatePluginNative(getPluginNative(), capabilities, src.pluginId);
3000
+ const deviceState = gateDeviceState(stateDomains);
3001
+ const voice = gateVoice(!!capabilities?.voice, src.pluginId);
3002
+ return { device: native, native, deviceState, voice };
3003
+ }
3004
+
2703
3005
  // src/tools/loader.ts
2704
3006
  import { getTenant as getTenant3 } from "@ateam-ai-mobile/core";
2705
3007
  var inflight = null;
@@ -2740,7 +3042,8 @@ async function ensureDeviceToolsLoaded({ force = false } = {}) {
2740
3042
  m.id,
2741
3043
  bundleUrl,
2742
3044
  m.version || "1.0.0",
2743
- getNativeBundleHash(m) || void 0
3045
+ getNativeBundleHash(m) || void 0,
3046
+ resolvePluginGrants({ pluginId: m.id, manifest: m })
2744
3047
  );
2745
3048
  } catch (err) {
2746
3049
  console.warn(`[deviceTools] ${m.id} failed to load:`, err?.message || String(err));
@@ -2759,7 +3062,7 @@ async function ensureDeviceToolsLoaded({ force = false } = {}) {
2759
3062
  }
2760
3063
  async function loadFromDiskCache() {
2761
3064
  try {
2762
- const index = await getBundleStorage().loadIndex();
3065
+ const index = await loadCacheIndex(getBundleStorage());
2763
3066
  const entries = Object.values(index || {});
2764
3067
  if (entries.length === 0) return [];
2765
3068
  const tenant = getTenant3();
@@ -2772,10 +3075,16 @@ async function loadFromDiskCache() {
2772
3075
  if (skipped > 0) {
2773
3076
  console.log(`[deviceTools] disk-cache recovery: ${mine.length} for "${tenant}", skipped ${skipped} not owned by it`);
2774
3077
  }
2775
- if (mine.length === 0) return [];
2776
- await Promise.all(mine.map(async (e) => {
3078
+ const declared = mine.filter((e) => !!e.trusted?.pluginId);
3079
+ if (declared.length < mine.length) {
3080
+ console.log(
3081
+ `[deviceTools] disk-cache recovery: skipped ${mine.length - declared.length} with no recorded manifest grant (fail closed)`
3082
+ );
3083
+ }
3084
+ if (declared.length === 0) return [];
3085
+ await Promise.all(declared.map(async (e) => {
2777
3086
  try {
2778
- await bundleManager.loadTools("cache:" + e.bundleUrl, e.bundleUrl, e.version, e.bundleHash);
3087
+ await bundleManager.loadTools(e.trusted.pluginId, e.bundleUrl, e.version, e.bundleHash, e.trusted);
2779
3088
  } catch {
2780
3089
  }
2781
3090
  }));
@@ -2790,11 +3099,14 @@ function resetDeviceToolLoader() {
2790
3099
  }
2791
3100
 
2792
3101
  export {
2793
- __export,
2794
3102
  register,
2795
3103
  registerTools,
2796
3104
  registerService,
2797
3105
  PLATFORM_VOICE_CALLER,
3106
+ isActionable,
3107
+ neverObserved,
3108
+ explainCapability,
3109
+ device_state_exports,
2798
3110
  View,
2799
3111
  Text,
2800
3112
  ScrollView,
@@ -2839,6 +3151,11 @@ export {
2839
3151
  setDeviceStateProvider,
2840
3152
  gateDeviceState,
2841
3153
  implementedStateDomains,
3154
+ setVoiceProvider,
3155
+ implementedVoice,
3156
+ gateVoice,
3157
+ resolvePluginGrants,
3158
+ resolvePluginRuntimeProps,
2842
3159
  useNativeBridge,
2843
3160
  createNativeBridge,
2844
3161
  setDeviceToolContextProvider,
@@ -2848,6 +3165,7 @@ export {
2848
3165
  listDeviceTools,
2849
3166
  callDeviceTool,
2850
3167
  clearDeviceTools,
3168
+ pluginBundleUrl,
2851
3169
  supportsNative,
2852
3170
  getNativeComponentId,
2853
3171
  getNativeBundleUrl,
@@ -2870,4 +3188,4 @@ export {
2870
3188
  ensureDeviceToolsLoaded,
2871
3189
  resetDeviceToolLoader
2872
3190
  };
2873
- //# sourceMappingURL=chunk-PKDQLNBL.mjs.map
3191
+ //# sourceMappingURL=chunk-SICFDA5K.mjs.map