@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.
@@ -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
+ }
@@ -5,6 +5,7 @@
5
5
  * default is in-memory (bundles re-download per app session — correct,
6
6
  * just slower on cold start).
7
7
  */
8
+ import type { PluginGrants } from '../pluginRuntime';
8
9
 
9
10
  export interface BundleCacheEntry {
10
11
  localPath: string;
@@ -13,6 +14,64 @@ export interface BundleCacheEntry {
13
14
  bundleUrl: string;
14
15
  /** SHA-256 (hex, lowercase) of the cached bundle bytes — integrity proof */
15
16
  bundleHash?: string;
17
+ /**
18
+ * Tenant whose session downloaded these bytes.
19
+ *
20
+ * A bundle URL is NOT tenant-independent: `/api/ui-plugins/<id>/bundle.js`
21
+ * resolves per-tenant on the server, so the SAME url returns different code
22
+ * for different tenants. On the normal load path the hash gate catches this
23
+ * for free — the expected hash comes from the current tenant's manifest and
24
+ * will not match another tenant's cached bytes. The disk-cache recovery path
25
+ * has no manifest to check against, so it must discriminate by tenant.
26
+ *
27
+ * Absent on entries written before this field existed: unknown ownership, so
28
+ * untrusted. Never read a missing tenant as "mine".
29
+ */
30
+ tenant?: string;
31
+ /**
32
+ * The trusted declaration these bytes' device tools were last registered
33
+ * under: the pluginId and grants resolved from the server manifest that named
34
+ * this bundle (resolvePluginGrants). Recorded so the disk-cache recovery path —
35
+ * which runs exactly when there is NO manifest — re-registers with the same
36
+ * authority instead of the bundle's own claim.
37
+ *
38
+ * Absent → no trusted declaration is known for these bytes, so recovery skips
39
+ * the entry (fail closed) and the catalog path registers it once the owning
40
+ * connector answers again.
41
+ */
42
+ trusted?: TrustedToolDeclaration;
43
+ }
44
+
45
+ /** Who a bundle's device tools belong to and what they are granted — always
46
+ * from the manifest, never from the bundle. */
47
+ export interface TrustedToolDeclaration extends PluginGrants {
48
+ pluginId: string;
49
+ }
50
+
51
+ /**
52
+ * Which cached entries belong to `tenant` and may therefore be re-evaluated.
53
+ *
54
+ * Lives here, beside the entry type, so "may I run this cached code" has ONE
55
+ * owner rather than being re-answered by each caller. `loadIndex()` returns
56
+ * every entry regardless of owner — it has to, since the manager looks entries
57
+ * up by url and rewrites them — so any caller that EVALUATES cached code must
58
+ * come through this function first.
59
+ *
60
+ * Fail-closed on both unknowns. An empty tenant owns nothing. An entry with no
61
+ * recorded tenant has unknown ownership and is never assumed to be the current
62
+ * one: every device already in the field has unstamped entries, so reading
63
+ * "missing" as "mine" would keep the hole open exactly where it is hardest to
64
+ * see.
65
+ *
66
+ * Compares the tenant field directly — it never builds a composite key, so
67
+ * there is no separator a tenant name could forge across.
68
+ */
69
+ export function selectOwnedCacheEntries<T extends { tenant?: string }>(
70
+ entries: T[],
71
+ tenant: string | null | undefined,
72
+ ): T[] {
73
+ if (!tenant) return [];
74
+ return (entries || []).filter((e) => !!e && e.tenant === tenant);
16
75
  }
17
76
 
18
77
  export interface BundleStorage {
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Host DI for the SEMANTIC layer — the `props.deviceState` counterpart of
3
+ * `setPluginNativeProvider`.
4
+ *
5
+ * Kept in its own file rather than added to host.ts, for the same reason the
6
+ * two layers have separate namespaces: the moment they share a module, the
7
+ * next person adds a domain to `PluginNative` or a capability to
8
+ * `stateDomains` and the boundary starts to blur.
9
+ *
10
+ * THE GATE IS NOT THE MECHANICAL ONE. `gatePluginNative` replaces an
11
+ * undeclared capability with a proxy whose methods warn and return `undefined`
12
+ * — so `await native.location.getCurrent()` resolves to `undefined`, and
13
+ * `pos?.latitude` is `undefined`, which flows into a model answer as a
14
+ * confident nothing. A silent undefined is a fabrication vector.
15
+ *
16
+ * Here, an undeclared domain returns a real snapshot in which everything is
17
+ * explicitly `unknown` / `null`, with `freshness.state = 'unknown'`. The
18
+ * plugin gets a well-formed answer that says "I know nothing", which no
19
+ * reasonable code path can mistake for data.
20
+ */
21
+ import type { DeviceBridgeDomain, DeviceState, StateQuality } from './sdk/device-state';
22
+
23
+ // The `unknown` freshness/confidence shape is the canonical `neverObserved()` +
24
+ // `{ level: 'unknown' }` (see sdk/device-state.ts). It is inlined here rather
25
+ // than imported because this module is imported directly by node --test and the
26
+ // harness resolves only the explicit entry, not a nested extensionless value
27
+ // import. The golden snapshot test pins this shape === neverObserved()'s.
28
+ const UNKNOWN_QUALITY: StateQuality = {
29
+ freshness: { state: 'unknown', observedAt: null, ageMs: null },
30
+ confidence: { level: 'unknown' },
31
+ };
32
+
33
+ /**
34
+ * The empty snapshot for a domain the plugin did not declare.
35
+ *
36
+ * Deliberately built per domain rather than one shared `{}`: each snapshot has
37
+ * required fields, and a caller reading `snapshot.position` must get `null`,
38
+ * not a TypeError. Domains not listed fall through to `{ quality }`, which is
39
+ * the one field every snapshot in the design shares.
40
+ */
41
+ function deniedSnapshot(domain: DeviceBridgeDomain): any {
42
+ const base = { quality: UNKNOWN_QUALITY };
43
+ switch (domain) {
44
+ case 'spatial':
45
+ return {
46
+ ...base,
47
+ position: null,
48
+ movement: { speedMps: null, bearingDeg: null, distanceRecentM: null, state: 'unknown' },
49
+ orientation: { headingDeg: null },
50
+ recentPath: [],
51
+ };
52
+ case 'visual':
53
+ return {
54
+ ...base,
55
+ camera: { available: false, active: false, facing: 'unknown' },
56
+ latest: null,
57
+ recentTimeline: { windowMs: 0, frames: [] },
58
+ };
59
+ case 'deviceContext':
60
+ return {
61
+ ...base,
62
+ power: { batteryLevel: null, charging: null, lowPowerMode: null },
63
+ thermal: { state: 'unknown' },
64
+ resources: { memoryPressure: 'unknown', storagePressure: 'unknown' },
65
+ };
66
+ case 'connectivity':
67
+ return {
68
+ ...base,
69
+ reachability: 'unknown',
70
+ transport: 'unknown',
71
+ qualityClass: 'unknown',
72
+ stability: 'unknown',
73
+ latencyMs: null,
74
+ };
75
+ case 'interaction':
76
+ return {
77
+ ...base,
78
+ lifecycle: { appState: 'unknown', screenOn: null },
79
+ audio: { captureActive: null, playbackActive: null, route: 'unknown', interruption: 'unknown' },
80
+ };
81
+ case 'activity':
82
+ return {
83
+ ...base,
84
+ // `confidence` is REQUIRED on both blocks in the freeze, precisely
85
+ // because an inferred activity must never be handed over without
86
+ // saying how sure the inference is. An empty snapshot is the least
87
+ // sure of all.
88
+ human: { state: 'unknown', since: null, confidence: { level: 'unknown' } },
89
+ devicePose: { placement: 'unknown', orientation: 'unknown', confidence: { level: 'unknown' } },
90
+ recentTransitions: [],
91
+ };
92
+ case 'environment':
93
+ return {
94
+ ...base,
95
+ lighting: { level: 'unknown', lux: null },
96
+ acoustics: { level: 'unknown', approximateDb: null },
97
+ // 'unknown' rather than 'uncertain': uncertain means we looked and
98
+ // could not decide, and nothing looked at all here.
99
+ enclosure: { state: 'unknown' },
100
+ proximity: { distanceClass: 'unknown' },
101
+ nearbySignals: { bleAvailable: false, beaconCount: null, uwbAvailable: false },
102
+ };
103
+ case 'capability': {
104
+ // Every axis unknown — and note this is NOT the same as `denied`.
105
+ // "I could not check" and "the user said no" lead to different things
106
+ // being said to the user, which is why the freeze keeps them apart.
107
+ const unknownStatus = { support: 'unknown', permission: 'unknown', availability: 'unknown' };
108
+ return {
109
+ ...base,
110
+ camera: unknownStatus,
111
+ microphone: unknownStatus,
112
+ location: { ...unknownStatus, precision: 'unknown', backgroundPermission: 'unknown' },
113
+ motion: unknownStatus,
114
+ bluetooth: unknownStatus,
115
+ uwb: unknownStatus,
116
+ nfc: unknownStatus,
117
+ notifications: unknownStatus,
118
+ health: unknownStatus,
119
+ backgroundExecution: unknownStatus,
120
+ };
121
+ }
122
+ default:
123
+ return base;
124
+ }
125
+ }
126
+
127
+ /**
128
+ * A domain the plugin may not use, or that this build does not implement.
129
+ *
130
+ * Reads answer with explicit unknowns. Anything that ACTS — start, stop,
131
+ * subscribe — throws, because there is no honest "unknown" for an action: a
132
+ * silent no-op start would leave the caller believing a session is open, and a
133
+ * subscription that never fires reads as "nothing has changed".
134
+ */
135
+ function deniedDomain(domain: DeviceBridgeDomain, why: string): any {
136
+ return {
137
+ async getSnapshot() { return deniedSnapshot(domain); },
138
+ async start() { throw denial(domain, why); },
139
+ async stop() { throw denial(domain, why); },
140
+ subscribe() { throw denial(domain, why); },
141
+ };
142
+ }
143
+
144
+ function denial(domain: DeviceBridgeDomain, why: string): Error {
145
+ const err: any = new Error(`deviceState.${domain}: ${why}`);
146
+ // Frozen vocabulary. A manifest gate IS a policy, so a manifest denial is
147
+ // policyRestricted — no new error vocabulary needed.
148
+ err.code = 'RESTRICTED';
149
+ err.domain = domain;
150
+ return err;
151
+ }
152
+
153
+ const ALL_DOMAINS: DeviceBridgeDomain[] = [
154
+ 'spatial', 'visual', 'activity', 'deviceContext',
155
+ 'connectivity', 'capability', 'environment', 'interaction',
156
+ ];
157
+
158
+ let _provider: Partial<DeviceState> = {};
159
+
160
+ /** Host DI: the app supplies the engines it has. Called once at startup. */
161
+ export function setDeviceStateProvider(state: Partial<DeviceState>): void {
162
+ _provider = state || {};
163
+ }
164
+
165
+ /**
166
+ * The `deviceState` a plugin receives, gated by what it declared.
167
+ *
168
+ * Every domain is always present — a plugin can call any of the eight without
169
+ * an existence check. What differs is whether it answers with data, or with
170
+ * an explicit "I know nothing" and a reason.
171
+ */
172
+ export function gateDeviceState(declared?: readonly DeviceBridgeDomain[]): DeviceState {
173
+ const allowed = new Set(declared || []);
174
+ const out: any = {};
175
+
176
+ for (const domain of ALL_DOMAINS) {
177
+ if (!allowed.has(domain)) {
178
+ out[domain] = deniedDomain(
179
+ domain,
180
+ `not declared in this plugin's stateDomains. Add '${domain}' to use it.`,
181
+ );
182
+ continue;
183
+ }
184
+ const engine = (_provider as any)[domain];
185
+ out[domain] = engine ?? deniedDomain(
186
+ domain,
187
+ 'no engine in this build — the native side does not exist yet. See docs/DEVICE_API_STATUS.md.',
188
+ );
189
+ }
190
+
191
+ return out as DeviceState;
192
+ }
193
+
194
+ /**
195
+ * The `deviceState` a device TOOL call receives: the same domains, but
196
+ * `subscribe` refuses.
197
+ *
198
+ * A declared domain IS the engine, and the engines live as long as the app. A
199
+ * UI plugin has a mount to unsubscribe in; a tool call has nothing — a listener
200
+ * `run()` adds outlives it and runs on every later event, once per call that
201
+ * added one. Refused loudly rather than quietly scoped to the call: a
202
+ * subscription that stops hearing when `run` returns reads as "nothing has
203
+ * changed". Everything else passes through untouched.
204
+ */
205
+ export function withoutSubscribe(state: DeviceState): DeviceState {
206
+ if (!state) return state;
207
+ const out: any = {};
208
+ for (const domain of ALL_DOMAINS) {
209
+ const d = (state as any)[domain];
210
+ out[domain] = d && Object.assign(Object.create(d), {
211
+ subscribe() {
212
+ throw denial(domain, 'a device tool call has no lifetime to own a subscription. Subscribe from the plugin UI, or read getSnapshot() within the call.');
213
+ },
214
+ });
215
+ }
216
+ return out as DeviceState;
217
+ }
218
+
219
+ /** Which domains this build can actually answer — for diagnostics. */
220
+ export function implementedStateDomains(): DeviceBridgeDomain[] {
221
+ return ALL_DOMAINS.filter(d => !!(_provider as any)[d]);
222
+ }
@@ -8,18 +8,23 @@
8
8
  * const { plugin, loading, error } = usePlugin(pluginId, manifest);
9
9
  * if (loading) return <Spinner />;
10
10
  * if (error) return <Error message={error} />;
11
- * return <plugin.Component bridge={bridge} native={native} theme={theme} context={context} />;
11
+ * return <plugin.Component bridge={bridge} device={device} theme={theme} context={context} />;
12
12
  */
13
13
  import { useState, useEffect } from 'react';
14
14
  import { bundleManager } from '../bundles/bundleManager';
15
15
  import { nativePluginRegistry } from '../host';
16
- import type { RegisteredUIPlugin } from '../sdk/types';
16
+ import { resolvePluginGrants } from '../pluginRuntime';
17
+ import type { NativeCapabilities, RegisteredUIPlugin } from '../sdk/types';
18
+ import type { DeviceBridgeDomain } from '../sdk/device-state';
17
19
 
18
20
  interface PluginManifestLike {
19
21
  version?: string;
20
22
  name?: string;
21
23
  render?: any;
22
24
  _connectorId?: string;
25
+ capabilities?: Record<string, unknown>;
26
+ native?: NativeCapabilities;
27
+ stateDomains?: DeviceBridgeDomain[];
23
28
  }
24
29
 
25
30
  interface UsePluginResult {
@@ -102,6 +107,11 @@ export function usePlugin(pluginId: string, manifest: PluginManifestLike): UsePl
102
107
  const bundleHash = extractBundleHash(manifest);
103
108
  const componentId = extractComponentId(manifest);
104
109
  const lookupId = componentId || pluginId;
110
+ // The manifest's grants — the authority for the bundle's device tools, the
111
+ // same one the renderer gates the component with. Keyed by value so a fresh
112
+ // manifest object with the same grants does not reload the plugin.
113
+ const grants = resolvePluginGrants({ pluginId, manifest });
114
+ const grantsKey = JSON.stringify(grants);
105
115
 
106
116
  if (ignoredUrl) {
107
117
  console.warn(`[usePlugin] ${pluginId}: IGNORED connector-injected bundleUrl="${ignoredUrl}". The host derives bundle URLs from pluginId via the canonical route.`);
@@ -132,7 +142,7 @@ export function usePlugin(pluginId: string, manifest: PluginManifestLike): UsePl
132
142
  // Path 2: Remote bundle (via global BundleManager)
133
143
  if (bundleUrl) {
134
144
  try {
135
- const component = await bundleManager.load(pluginId, bundleUrl, version, bundleHash);
145
+ const component = await bundleManager.load(pluginId, bundleUrl, version, bundleHash, grants);
136
146
  if (cancelled) return;
137
147
  setPlugin(component);
138
148
  setLoading(false);
@@ -179,7 +189,7 @@ export function usePlugin(pluginId: string, manifest: PluginManifestLike): UsePl
179
189
  })();
180
190
 
181
191
  return () => { cancelled = true; };
182
- }, [pluginId, lookupId, bundleUrl, bundleHash, version]);
192
+ }, [pluginId, lookupId, bundleUrl, bundleHash, version, grantsKey]);
183
193
 
184
194
  return { plugin, loading, error, bundleMissing };
185
195
  }
package/src/host.ts CHANGED
@@ -41,19 +41,23 @@ const noopNative: PluginNative = {
41
41
  biometrics: unavailable('biometrics'),
42
42
  biometricsV2: unavailable('biometricsV2'),
43
43
  notifications: unavailable('notifications'),
44
- haptics: { impact: () => {}, notification: () => {}, selection: () => {} },
44
+ haptics: { impact: () => {}, notification: () => {}, selection: () => {}, error: () => {} },
45
45
  connectivity: { isOnline: () => true, onChange: () => () => {} },
46
46
  fileSystem: unavailable('fileSystem'),
47
47
  cookies: unavailable('cookies'),
48
48
  sharing: unavailable('sharing'),
49
49
  scanner: unavailable('scanner'),
50
50
  nfc: unavailable('nfc'),
51
+ bluetooth: unavailable('bluetooth'),
52
+ vision: unavailable('vision'),
53
+ maps: unavailable('maps'),
51
54
  };
52
55
 
53
56
  let _pluginNative: PluginNative = noopNative;
54
57
 
55
58
  /** Install the device-capabilities implementation plugins receive as
56
- * `props.native`. The host owns the native modules. */
59
+ * `props.device` (the L1 native modules keep the `native` name; the plugin-
60
+ * facing prop is `device`). The host owns the native modules. */
57
61
  export function setPluginNativeProvider(native: PluginNative): void {
58
62
  _pluginNative = native;
59
63
  }
@@ -64,12 +68,12 @@ export function getPluginNative(): PluginNative {
64
68
 
65
69
  /** Blocked-capability proxy: any call warns and is ignored (no throw) —
66
70
  * matches the reference app's behaviour for undeclared capabilities. */
67
- function blocked(api: string): any {
71
+ function blocked(api: string, reason?: string): any {
68
72
  return new Proxy({}, {
69
73
  get: (_t, method) => {
70
74
  if (typeof method !== 'string') return undefined;
71
75
  return () => {
72
- console.warn(`[adas] native.${api}.${method} blocked — capability '${api}' not declared in the plugin manifest`);
76
+ console.warn(`[adas] native.${api}.${method} blocked — ${reason ?? `capability '${api}' not declared in the plugin manifest`}`);
73
77
  };
74
78
  },
75
79
  });
@@ -87,23 +91,93 @@ function blocked(api: string): any {
87
91
  export function gatePluginNative(
88
92
  native: PluginNative,
89
93
  declared?: NativeCapabilities,
94
+ pluginId?: string,
90
95
  ): PluginNative {
91
96
  const caps = declared || {};
92
- return {
97
+ // Per-plugin filesystem + storage isolation, FAIL CLOSED. A declared plugin
98
+ // gets a sandbox/namespace scoped to its OWN id (plugin-files/<pluginId>/,
99
+ // @plugin:<id>: KV, plugin-storage-<id>.db) via the provider's
100
+ // fileSystemFor/storageFor. There is deliberately NO shared fallback: if the
101
+ // id is missing or the provider can't scope, the plugin is BLOCKED, not handed
102
+ // a bucket some other plugin also holds. A silent shared fallback is exactly
103
+ // the P1 that reopened when the UI renderer omitted the id — the isolation must
104
+ // fail closed, never degrade to "shared" without anyone noticing.
105
+ const scopedFileSystem =
106
+ native.fileSystemFor && pluginId ? native.fileSystemFor(pluginId) : null;
107
+ const scopedStorage =
108
+ native.storageFor && pluginId ? native.storageFor(pluginId) : null;
109
+ const gated = {
93
110
  camera: caps.camera ? native.camera : blocked('camera'),
94
111
  location: caps.location ? native.location : blocked('location'),
95
- storage: caps.offline ? native.storage : blocked('storage'),
112
+ storage: caps.offline
113
+ ? (scopedStorage ?? blocked('storage', "no trusted pluginId to scope storage — refusing shared access (fail closed)"))
114
+ : blocked('storage'),
96
115
  biometrics: caps.biometrics ? native.biometrics : blocked('biometrics'),
97
116
  biometricsV2: caps.biometricsV2 ? native.biometricsV2 : blocked('biometricsV2'),
98
117
  notifications: caps.notifications ? native.notifications : blocked('notifications'),
99
118
  haptics: caps.haptics ? native.haptics : blocked('haptics'),
100
119
  connectivity: native.connectivity,
101
- fileSystem: caps.fileSystem ? native.fileSystem : blocked('fileSystem'),
120
+ fileSystem: caps.fileSystem
121
+ ? (scopedFileSystem ?? blocked('fileSystem', "no trusted pluginId to scope the filesystem — refusing shared access (fail closed)"))
122
+ : blocked('fileSystem'),
102
123
  cookies: caps.cookies ? native.cookies : blocked('cookies'),
103
124
  sharing: caps.sharing ? native.sharing : blocked('sharing'),
104
125
  scanner: caps.scanner ? native.scanner : blocked('scanner'),
105
126
  nfc: caps.nfc ? native.nfc : blocked('nfc'),
106
- };
127
+ // Gated like the rest even though nothing implements it yet. An undeclared
128
+ // plugin gets the usual blocked proxy; a DECLARED one reaches an API whose
129
+ // every method refuses by name. Both are better than the `undefined` this
130
+ // used to be, which crashed with "cannot read properties of undefined".
131
+ bluetooth: caps.bluetooth ? native.bluetooth : blocked('bluetooth'),
132
+ vision: caps.vision ? native.vision : blocked('vision'),
133
+ maps: caps.maps ? native.maps : blocked('maps'),
134
+ // What this plugin DECLARED, carried on the object it already receives.
135
+ //
136
+ // It is how a COMPONENT can be capability-gated: <CameraPreview> is handed
137
+ // `native` and reads this, so showing the camera obeys the same flag as
138
+ // calling it. Without this the preview would be a second door to the camera
139
+ // with only one lock. Non-enumerable so it cannot be mistaken for an API.
140
+ } as any;
141
+
142
+ Object.defineProperty(gated, '__declaredCapabilities', {
143
+ value: Object.freeze({ ...caps }),
144
+ enumerable: false,
145
+ writable: false,
146
+ });
147
+ return gated;
148
+ }
149
+
150
+ // ── Camera preview (the plugin-facing way to SHOW the camera) ─────────────
151
+ //
152
+ // A plugin composes <CameraPreview native={native} /> from the SDK; the HOST
153
+ // supplies what actually renders, because only the host has the native module.
154
+ //
155
+ // This exists so we do NOT register raw `expo-camera` for bundles. That would
156
+ // be a third door to the device with no capability gate and no honest failure
157
+ // mode — a plugin could mount a camera without declaring it. Here the pixels
158
+ // stay on the host's side: a plugin can SHOW the camera and still cannot touch
159
+ // a frame.
160
+
161
+ /** Props the host's preview implementation receives. */
162
+ export interface CameraPreviewImplProps {
163
+ facing?: 'front' | 'back';
164
+ style?: any;
165
+ renderUnavailable?: (reason: string) => any;
166
+ }
167
+
168
+ let _cameraPreview: ((props: CameraPreviewImplProps) => any) | null = null;
169
+
170
+ /** Install the host's camera preview (e.g. an expo-camera CameraView). */
171
+ export function setCameraPreviewProvider(
172
+ Component: ((props: CameraPreviewImplProps) => any) | null,
173
+ ): void {
174
+ _cameraPreview = Component;
175
+ }
176
+
177
+ /** Null when the host installed none — the SDK then renders a stated reason,
178
+ * never a black rectangle that looks like a dark room. */
179
+ export function getCameraPreviewProvider(): ((props: CameraPreviewImplProps) => any) | null {
180
+ return _cameraPreview;
107
181
  }
108
182
 
109
183
  // ── Native (host-bundled) plugin registry ─────────────────────────────────
package/src/index.ts CHANGED
@@ -13,14 +13,76 @@
13
13
  * No Expo imports — @ateam-ai-mobile/expo supplies the Expo-backed providers.
14
14
  */
15
15
 
16
- // Plugin SDK (plugin-author surface)
16
+ // Plugin SDK (plugin-author surface) — the MECHANICAL layer.
17
+ // `props.device.*`: verbs, bare values, throws when it cannot.
17
18
  export * from './sdk';
18
19
 
20
+ // Device Bridge v2 — the SEMANTIC layer. `props.deviceState.*`: state names,
21
+ // snapshots carrying freshness and confidence, and reads that say `unknown`
22
+ // rather than throwing.
23
+ //
24
+ // Exported under its own path rather than star-exported into this namespace,
25
+ // deliberately: the whole point of the layer is that a reader can tell at a
26
+ // glance which one they are in, and merging both into one import undoes that.
27
+ export * as deviceStateV2 from './sdk/device-state';
28
+ export type {
29
+ DeviceState,
30
+ DeviceBridgeDomain,
31
+ StateQuality,
32
+ Freshness,
33
+ Confidence,
34
+ VisualSnapshot,
35
+ SpatialSnapshot,
36
+ SpatialPoint,
37
+ SpatialIntent,
38
+ SpatialEvent,
39
+ SpatialStateEvent,
40
+ StateEvent,
41
+ StateListener,
42
+ Subscription,
43
+ ActivitySnapshot,
44
+ EnvironmentSnapshot,
45
+ InteractionSnapshot,
46
+ CapabilitySnapshot,
47
+ CapabilityStatus,
48
+ DeviceContextSnapshot,
49
+ ConnectivitySnapshot,
50
+ } from './sdk/device-state';
51
+ // Device Bridge v2 runtime helpers (VALUES). `neverObserved` is the canonical
52
+ // `unknown` freshness the engine's quality module derives from; the others were
53
+ // already in the built types but missing from the source root export.
54
+ export {
55
+ neverObserved,
56
+ isActionable,
57
+ explainCapability,
58
+ } from './sdk/device-state';
59
+
60
+ // Device Bridge v2 host DI — the semantic counterpart of
61
+ // setPluginNativeProvider. Separate module on purpose: the moment the two
62
+ // layers share one, the boundary starts to blur.
63
+ export {
64
+ setDeviceStateProvider,
65
+ gateDeviceState,
66
+ implementedStateDomains,
67
+ } from './deviceStateHost';
68
+
69
+ // Voice session host DI — the app hands its one live session here, and the gate
70
+ // (called only via resolvePluginRuntimeProps) decides per-plugin exposure. Its
71
+ // own file for the same reason deviceState is: a session is neither a mechanical
72
+ // verb nor a device domain, and merging it in would blur that.
73
+ export {
74
+ setVoiceProvider,
75
+ gateVoice,
76
+ implementedVoice,
77
+ } from './voiceHost';
78
+
19
79
  // Host integration
20
80
  export {
21
81
  setHapticsProvider,
22
82
  getHaptics,
23
83
  setPluginNativeProvider,
84
+ setCameraPreviewProvider,
85
+ getCameraPreviewProvider,
24
86
  getPluginNative,
25
87
  gatePluginNative,
26
88
  registerNativePlugin,
@@ -29,10 +91,42 @@ export {
29
91
  registerBundleModules,
30
92
  } from './host';
31
93
 
94
+ // The ONE runtime contract that gates a plugin's device surface (device/native +
95
+ // deviceState) — every renderer AND its device tools resolve grants through it,
96
+ // so a plugin gets identical access on every surface and in its tools.
97
+ export { resolvePluginRuntimeProps, resolvePluginGrants } from './pluginRuntime';
98
+ export type { PluginRuntimeProps, PluginRuntimeSource, PluginGrants } from './pluginRuntime';
99
+
100
+ // Device tools (plugin `tools/` sections — registered at bundle load)
101
+ export {
102
+ registerDeviceTools,
103
+ unregisterDeviceTools,
104
+ setDeviceToolContextProvider,
105
+ callDeviceTool,
106
+ listDeviceTools,
107
+ hasDeviceTool,
108
+ clearDeviceTools,
109
+ } from './tools/registry';
110
+ export type { DeviceToolSummary, DeviceToolOutcome } from './tools/registry';
111
+ export { ensureDeviceToolsLoaded, resetDeviceToolLoader } from './tools/loader';
112
+ export { registerPlatformDeviceTools } from './tools/platformTools';
113
+ export {
114
+ syncDeviceToolsForSession,
115
+ deviceToolsActionForSession,
116
+ __resetSessionSyncForTest,
117
+ } from './tools/sessionSync';
118
+ export type { SessionSnapshot, DeviceToolsAction, DeviceToolsSessionDeps } from './tools/sessionSync';
119
+ export {
120
+ handleServerDeviceToolCall,
121
+ isDeviceToolCommand,
122
+ setDeviceToolResultPoster,
123
+ } from './tools/serverCalls';
124
+
32
125
  // Bundles
33
126
  export { bundleManager } from './bundles/bundleManager';
127
+ export type { LoadedBundleInfo, LoadedBundleSource } from './bundles/bundleManager';
34
128
  export { setBundleStorage, MemoryBundleStorage } from './bundles/storage';
35
- export type { BundleStorage, BundleCacheEntry } from './bundles/storage';
129
+ export type { BundleStorage, BundleCacheEntry, TrustedToolDeclaration } from './bundles/storage';
36
130
 
37
131
  // Bridge
38
132
  export { useNativeBridge, createNativeBridge } from './bridge/native';