@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.
- package/dist/{chunk-PKDQLNBL.mjs → chunk-SICFDA5K.mjs} +388 -70
- package/dist/chunk-SICFDA5K.mjs.map +1 -0
- package/dist/index.d.mts +462 -46
- package/dist/index.d.ts +462 -46
- package/dist/index.js +429 -230
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +20 -157
- package/dist/index.mjs.map +1 -1
- package/dist/{loader-5LUVXUY2.mjs → loader-MSZ4PVTO.mjs} +2 -2
- package/package.json +2 -2
- package/src/api/plugins.ts +15 -9
- package/src/bundles/bundleManager.ts +187 -64
- package/src/bundles/bundleModuleNames.ts +52 -0
- package/src/bundles/bundleUrl.ts +41 -0
- package/src/bundles/storage.ts +71 -0
- package/src/deviceStateHost.ts +25 -0
- package/src/hooks/usePlugin.ts +15 -4
- package/src/host.ts +2 -0
- package/src/index.ts +16 -37
- package/src/pluginRuntime.ts +22 -11
- package/src/sdk/CameraPreview.tsx +7 -0
- package/src/sdk/device-state.ts +120 -17
- package/src/sdk/index.ts +41 -0
- package/src/sdk/registration.ts +2 -1
- package/src/sdk/types.ts +162 -5
- package/src/sdk/voice.ts +82 -0
- package/src/tools/loader.ts +28 -8
- package/src/tools/registry.ts +14 -7
- package/src/voiceHost.ts +2 -0
- package/dist/chunk-PKDQLNBL.mjs.map +0 -1
- /package/dist/{loader-5LUVXUY2.mjs.map → loader-MSZ4PVTO.mjs.map} +0 -0
package/src/sdk/types.ts
CHANGED
|
@@ -352,6 +352,139 @@ export interface VisionAPI {
|
|
|
352
352
|
assessQuality(frameId: string): Promise<VisionFrameQuality>;
|
|
353
353
|
}
|
|
354
354
|
|
|
355
|
+
// ── Maps — where is X, what is here, how do I walk there ──
|
|
356
|
+
//
|
|
357
|
+
// One-shot verbs that THROW, like vision — not a state domain. Following a
|
|
358
|
+
// route over time (progress, off-route, "turn left in 20 m") is the reasoning
|
|
359
|
+
// loop's job over deviceState.spatial; this layer answers each question once.
|
|
360
|
+
//
|
|
361
|
+
// NOT ON-DEVICE — the opposite of vision, and the thing not to get wrong when
|
|
362
|
+
// describing a feature built on it. Every call sends the query and the
|
|
363
|
+
// coordinates you pass to a map service over the network: Apple's (MapKit /
|
|
364
|
+
// CLGeocoder) on iOS, the platform geocoder on Android. So the `maps` flag
|
|
365
|
+
// means exactly one thing: "this plugin may send place queries to Apple Maps".
|
|
366
|
+
//
|
|
367
|
+
// IT NEVER READS THE USER'S LOCATION. Every coordinate is the caller's — take a
|
|
368
|
+
// fix from `location.getCurrent` or `deviceState.spatial` and pass it in. That
|
|
369
|
+
// keeps `maps` from becoming a second, differently-gated door to where the
|
|
370
|
+
// user is.
|
|
371
|
+
//
|
|
372
|
+
// FOUND NOTHING IS AN ANSWER; COULD NOT LOOK THROWS. `[]` / `null` mean the
|
|
373
|
+
// service looked and there is nothing. Anything else THROWS, with a code:
|
|
374
|
+
// UNAVAILABLE this app build has no AdaMaps module (needs a native rebuild),
|
|
375
|
+
// or the platform has none — search/route are iOS only (MapKit)
|
|
376
|
+
// and refuse by name on Android. Also the service's own failures.
|
|
377
|
+
// OFFLINE the map service could not be reached.
|
|
378
|
+
// THROTTLED Apple is rate-limiting this app. A burst of queries is what
|
|
379
|
+
// trips it — search once per intent, not once per frame.
|
|
380
|
+
// And one refusal that is not a failure to look:
|
|
381
|
+
// INVALID_ARGUMENT the call itself is malformed — an empty query, a
|
|
382
|
+
// coordinate that is not { latitude, longitude } finite numbers in
|
|
383
|
+
// range (`{ lat, lng }` refuses), a limit or radius out of range.
|
|
384
|
+
// Nothing was sent; fix the call rather than retrying it.
|
|
385
|
+
|
|
386
|
+
/** The same `{ latitude, longitude }` `location.getCurrent` returns, so a fix
|
|
387
|
+
* passes straight in. */
|
|
388
|
+
export interface MapsCoordinate {
|
|
389
|
+
latitude: number;
|
|
390
|
+
longitude: number;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** A postal address, field by field. `null` = the map data has no value — never
|
|
394
|
+
* an empty string, which would read as a street with no name. */
|
|
395
|
+
export interface MapsAddress {
|
|
396
|
+
/** The placemark's own name — a building, or often number + street. */
|
|
397
|
+
name: string | null;
|
|
398
|
+
street: string | null;
|
|
399
|
+
streetNumber: string | null;
|
|
400
|
+
/** Neighbourhood / sub-locality. */
|
|
401
|
+
district: string | null;
|
|
402
|
+
city: string | null;
|
|
403
|
+
/** State / province / administrative area. */
|
|
404
|
+
region: string | null;
|
|
405
|
+
postalCode: string | null;
|
|
406
|
+
country: string | null;
|
|
407
|
+
/** ISO 3166-1 alpha-2, e.g. "GB". */
|
|
408
|
+
isoCountryCode: string | null;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export interface MapsPlace {
|
|
412
|
+
/** What the map calls it ("Boots", "10 Downing St"). Null when unnamed. */
|
|
413
|
+
name: string | null;
|
|
414
|
+
coordinate: MapsCoordinate;
|
|
415
|
+
address: MapsAddress;
|
|
416
|
+
/** Apple's point-of-interest category as a vocabulary term ("Pharmacy",
|
|
417
|
+
* "Cafe", "PublicTransport") — NOT free text. Null for a plain address, or
|
|
418
|
+
* when the service gives none. */
|
|
419
|
+
category: string | null;
|
|
420
|
+
/** Straight-line metres from `opts.near`, when you passed one — NOT walking
|
|
421
|
+
* distance (ask `route` for that). Null without `near`. */
|
|
422
|
+
distanceM: number | null;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export interface MapsRouteStep {
|
|
426
|
+
/** Apple's instruction ("Turn right onto Baker St"). The FIRST step is usually
|
|
427
|
+
* an origin step with an empty instruction — kept, not dropped, so step
|
|
428
|
+
* indexes line up with Apple's. */
|
|
429
|
+
instructions: string;
|
|
430
|
+
distanceM: number;
|
|
431
|
+
/** An advisory Apple attaches to this step. Null when there is none. */
|
|
432
|
+
notice: string | null;
|
|
433
|
+
polyline: MapsCoordinate[];
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export interface MapsRoute {
|
|
437
|
+
mode: 'walking';
|
|
438
|
+
distanceM: number;
|
|
439
|
+
/** Apple's estimate for the whole route. */
|
|
440
|
+
expectedTravelTimeMs: number;
|
|
441
|
+
steps: MapsRouteStep[];
|
|
442
|
+
/** The whole route, start to end. */
|
|
443
|
+
polyline: MapsCoordinate[];
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export interface MapsAPI {
|
|
447
|
+
/** Can this build search and route? True when the AdaMaps module is in the
|
|
448
|
+
* binary (iOS). False on Android and on an older build — `reverseGeocode`
|
|
449
|
+
* does not need the module and may still work there. */
|
|
450
|
+
isSupported(): Promise<boolean>;
|
|
451
|
+
/**
|
|
452
|
+
* Find places by free text — "pharmacy", "10 Downing Street".
|
|
453
|
+
*
|
|
454
|
+
* `near` centres the search on a coordinate YOU supply, within `radiusM`
|
|
455
|
+
* (default 3000, above 0 and at most 100 000). On iOS 18+ results are
|
|
456
|
+
* confined to that area; before 18 it is a strong hint and a farther result
|
|
457
|
+
* can come back — each result's `distanceM` says how far. At most `limit`
|
|
458
|
+
* results (default 8, from 1; more than 25 is capped at 25).
|
|
459
|
+
*
|
|
460
|
+
* Network, Apple's servers. `[]` = Apple looked and found nothing. iOS only:
|
|
461
|
+
* throws UNAVAILABLE on Android or a build without AdaMaps.
|
|
462
|
+
*/
|
|
463
|
+
search(query: string, opts?: { near?: MapsCoordinate; radiusM?: number; limit?: number }): Promise<MapsPlace[]>;
|
|
464
|
+
/**
|
|
465
|
+
* What is at a coordinate — its address and name.
|
|
466
|
+
*
|
|
467
|
+
* iOS AND Android (the platform geocoder); needs no AdaMaps module. The
|
|
468
|
+
* result's `coordinate` is the point you asked about, and its
|
|
469
|
+
* `category` / `distanceM` are null. `null` = nothing there.
|
|
470
|
+
*
|
|
471
|
+
* Network, not on-device. iOS reports a rate limit and a lost connection as
|
|
472
|
+
* the SAME error, so OFFLINE here can also mean "too many requests". Android's
|
|
473
|
+
* geocoder refuses without location permission (UNAVAILABLE) — that is the
|
|
474
|
+
* platform's rule, not a location read.
|
|
475
|
+
*/
|
|
476
|
+
reverseGeocode(at: MapsCoordinate): Promise<MapsPlace | null>;
|
|
477
|
+
/**
|
|
478
|
+
* A walking route between two coordinates YOU supply.
|
|
479
|
+
*
|
|
480
|
+
* `null` = Apple found no route — never a fake 0 m route, which would read as
|
|
481
|
+
* "you have arrived". Walking is the only mode; any other refuses by name.
|
|
482
|
+
* Network, Apple's servers. iOS only: throws UNAVAILABLE on Android or a build
|
|
483
|
+
* without AdaMaps.
|
|
484
|
+
*/
|
|
485
|
+
route(from: MapsCoordinate, to: MapsCoordinate, opts?: { mode?: 'walking' }): Promise<MapsRoute | null>;
|
|
486
|
+
}
|
|
487
|
+
|
|
355
488
|
export interface BluetoothAPI {
|
|
356
489
|
/** Is BLE hardware present and permitted? */
|
|
357
490
|
isSupported(): Promise<boolean>;
|
|
@@ -420,6 +553,9 @@ export interface PluginNative {
|
|
|
420
553
|
bluetooth: BluetoothAPI;
|
|
421
554
|
/** SHAPE ONLY in this build — see VisionAPI. On-device image understanding. */
|
|
422
555
|
vision: VisionAPI;
|
|
556
|
+
/** Place search, reverse geocoding, walking routes — see MapsAPI. Sends
|
|
557
|
+
* queries to Apple's map servers; NOT on-device. */
|
|
558
|
+
maps: MapsAPI;
|
|
423
559
|
}
|
|
424
560
|
|
|
425
561
|
// ── Native capability flags (manifest declaration) ──────────
|
|
@@ -438,6 +574,9 @@ export interface NativeCapabilities {
|
|
|
438
574
|
/** On-device image understanding (OCR, labels, frame quality). Refuses by
|
|
439
575
|
* name until the native module lands — see VisionAPI. */
|
|
440
576
|
vision?: boolean;
|
|
577
|
+
/** Place search, reverse geocoding, walking routes. Means "may send place
|
|
578
|
+
* queries to Apple Maps" — network, not on-device. See MapsAPI. */
|
|
579
|
+
maps?: boolean;
|
|
441
580
|
/** The live voice SESSION (props.voice). A privacy surface — a declaring
|
|
442
581
|
* plugin can read the conversation transcript — so it is gated like any
|
|
443
582
|
* other capability, and coupled to the platform's unsuppressable mic
|
|
@@ -570,8 +709,9 @@ export interface PluginManifest {
|
|
|
570
709
|
channels?: string[] | { events: string[]; actions: string[] };
|
|
571
710
|
capabilities?: Record<string, unknown>;
|
|
572
711
|
/** L3 DeviceState domains this plugin reads (props.deviceState), as Core passes
|
|
573
|
-
* them through from the
|
|
574
|
-
* bundle's own `stateDomains
|
|
712
|
+
* them through from the solution's declaration. Absent → NO domains (fail
|
|
713
|
+
* closed): the loaded bundle's own `stateDomains` is never consulted when a
|
|
714
|
+
* manifest exists. Read via resolvePluginRuntimeProps. */
|
|
575
715
|
stateDomains?: DeviceBridgeDomain[];
|
|
576
716
|
_source?: string;
|
|
577
717
|
_connectorId?: string;
|
|
@@ -616,6 +756,9 @@ export interface DeviceToolContext {
|
|
|
616
756
|
* the same gating a UI plugin's `props.deviceState` gets. This is what lets an
|
|
617
757
|
* EXTERNAL tool (e.g. Ada Guide's `guide.observe`) read `ctx.deviceState.visual`
|
|
618
758
|
* over the public surface, with no SDK-internal access.
|
|
759
|
+
*
|
|
760
|
+
* `subscribe` refuses (RESTRICTED) on every domain: a call has no lifetime to
|
|
761
|
+
* own a subscription, and the engines outlive it. Read instead.
|
|
619
762
|
*/
|
|
620
763
|
deviceState: DeviceState;
|
|
621
764
|
/** Cooperative cancellation: caller cancelled, or the session went away. */
|
|
@@ -624,6 +767,19 @@ export interface DeviceToolContext {
|
|
|
624
767
|
|
|
625
768
|
export type DeviceToolFn = (args: any, ctx: DeviceToolContext) => Promise<any> | any;
|
|
626
769
|
|
|
770
|
+
/**
|
|
771
|
+
* Why running a device tool produced no result (`callDeviceTool`'s
|
|
772
|
+
* `{ ok: false, reason }`). The one list: the registry returns these, and the
|
|
773
|
+
* voice snapshot's `lastDeviceTool.reason` carries them unchanged.
|
|
774
|
+
* - `not_found` — no tool is registered under that name.
|
|
775
|
+
* - `no_context` — the tool's device context could not be built.
|
|
776
|
+
* - `timeout` — the tool did not finish within the call's timeout.
|
|
777
|
+
* - `failed` — the tool threw; the error is its message.
|
|
778
|
+
* A tool that RETURNS a value has succeeded as far as the platform is concerned,
|
|
779
|
+
* even if the value describes a failure — throw to report one.
|
|
780
|
+
*/
|
|
781
|
+
export type DeviceToolFailureReason = 'not_found' | 'no_context' | 'timeout' | 'failed';
|
|
782
|
+
|
|
627
783
|
export interface DeviceToolDefinition {
|
|
628
784
|
/** Domain name, as the catalog and the caller see it. e.g. "guide.observe" */
|
|
629
785
|
name: string;
|
|
@@ -639,9 +795,10 @@ export interface RegisteredToolsPlugin {
|
|
|
639
795
|
type: 'tools';
|
|
640
796
|
capabilities?: NativeCapabilities;
|
|
641
797
|
/**
|
|
642
|
-
* L3 DeviceState domains this plugin's tools
|
|
643
|
-
*
|
|
644
|
-
*
|
|
798
|
+
* L3 DeviceState domains this plugin's tools read via `ctx.deviceState` (e.g.
|
|
799
|
+
* `['visual']`). For a remote bundle this is the author's statement, not a
|
|
800
|
+
* grant: the tools get the manifest's `stateDomains` (resolvePluginGrants),
|
|
801
|
+
* and a domain declared here but not there is denied with a warning.
|
|
645
802
|
*/
|
|
646
803
|
stateDomains?: DeviceBridgeDomain[];
|
|
647
804
|
tools: DeviceToolDefinition[];
|
package/src/sdk/voice.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*
|
|
19
19
|
* See docs/handoff/SPEC_voice_session_without_the_voice_screen_2026-09-12.md.
|
|
20
20
|
*/
|
|
21
|
+
import type { DeviceToolFailureReason } from './types';
|
|
21
22
|
|
|
22
23
|
/** The session lifecycle — identical vocabulary to the built-in `CallState`. */
|
|
23
24
|
export type VoiceState = 'idle' | 'connecting' | 'connected' | 'error';
|
|
@@ -29,6 +30,41 @@ export interface VoiceTranscriptEntry {
|
|
|
29
30
|
ts: number;
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Why a device-tool call the session received did not produce a result.
|
|
35
|
+
* - the tool RAN and did not return: `not_found` | `no_context` | `timeout` |
|
|
36
|
+
* `failed` (it threw) — see DeviceToolFailureReason;
|
|
37
|
+
* - the phone REFUSED it before running: `not_ready` (Core's tool allowset for
|
|
38
|
+
* this session had not arrived yet — transient, a retry can succeed) or
|
|
39
|
+
* `not_authorized` (not allowed in this session — final);
|
|
40
|
+
* - `invalid`: the call itself was malformed (it named no tool, or carried no
|
|
41
|
+
* correlationId to answer on) — not run.
|
|
42
|
+
*/
|
|
43
|
+
export type VoiceDeviceToolReason = DeviceToolFailureReason | 'not_ready' | 'not_authorized' | 'invalid';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* One finished device-tool call, as `VoiceSnapshot.lastDeviceTool` reports it.
|
|
47
|
+
* `name` and `at` keep their original meaning, so a reader of just those two is
|
|
48
|
+
* unaffected.
|
|
49
|
+
*/
|
|
50
|
+
export type VoiceDeviceToolCall = {
|
|
51
|
+
/** The tool the model called ('' if the call named none — see `invalid`). */
|
|
52
|
+
name: string;
|
|
53
|
+
/** Epoch ms the call REACHED the phone (not when it finished — that is at + ms). */
|
|
54
|
+
at: number;
|
|
55
|
+
/** Milliseconds from arrival to outcome (≈0 for a refusal). */
|
|
56
|
+
ms: number;
|
|
57
|
+
} & (
|
|
58
|
+
| { ok: true; reason: null; error: null }
|
|
59
|
+
| {
|
|
60
|
+
ok: false;
|
|
61
|
+
reason: VoiceDeviceToolReason;
|
|
62
|
+
/** What went wrong, clipped to 200 characters: tool errors can echo the
|
|
63
|
+
* call's arguments, which may carry what the user said. */
|
|
64
|
+
error: string;
|
|
65
|
+
}
|
|
66
|
+
);
|
|
67
|
+
|
|
32
68
|
/** A point-in-time read of the session — what a reasoning loop polls. */
|
|
33
69
|
export interface VoiceSnapshot {
|
|
34
70
|
state: VoiceState;
|
|
@@ -52,12 +88,58 @@ export interface VoiceSnapshot {
|
|
|
52
88
|
/** The last error, if `state === 'error'`; also carries a refusal reason when
|
|
53
89
|
* this surface was handed to a plugin that did not declare `voice`. */
|
|
54
90
|
lastError: string | null;
|
|
91
|
+
/**
|
|
92
|
+
* The most recently FINISHED device-tool call the model made through this
|
|
93
|
+
* session, with its outcome — or null if none has finished yet. Set once the
|
|
94
|
+
* outcome is known (ran, refused, malformed, threw, timed out), never on
|
|
95
|
+
* arrival, so a refused call can no longer look like one that ran.
|
|
96
|
+
*
|
|
97
|
+
* SCOPED to the caller: a plugin sees only calls to tools IT registered (never
|
|
98
|
+
* another solution's tool names or errors, even on its own call); the platform
|
|
99
|
+
* sees every call. Cleared when the session ends or a new one starts.
|
|
100
|
+
* Diagnostic — every solution's debug view gets "why did that call fail" free.
|
|
101
|
+
*/
|
|
102
|
+
lastDeviceTool: VoiceDeviceToolCall | null;
|
|
103
|
+
/** The sessionId the Core allowset is bound to. Diagnostic: authorization
|
|
104
|
+
* requires it to equal `sessionId`; a mismatch is why tools refuse. */
|
|
105
|
+
authorizedSessionId: string | null;
|
|
55
106
|
}
|
|
56
107
|
|
|
57
108
|
export interface VoiceStartOptions {
|
|
58
109
|
/** Hand the mic to a specific skill on connect (voice /session → takeMic),
|
|
59
110
|
* exactly as `sys.focusUiPlugin("platform:voice", { skillSlug })` does. */
|
|
60
111
|
skillSlug?: string;
|
|
112
|
+
/**
|
|
113
|
+
* A PER-CALL persona override, same shape as the authored
|
|
114
|
+
* `solution.voice.persona` ({ name, style }). Declared on the voice
|
|
115
|
+
* session-create call (POST /session `persona`); the server sanitizes it
|
|
116
|
+
* (name/style only, length-capped) and layers it OVER the solution's authored
|
|
117
|
+
* persona for THIS call only — prompt-layer, so it can never touch caller
|
|
118
|
+
* verification or tool allow-lists. Set once at session start and constant
|
|
119
|
+
* for the call, so it adds no per-turn prompt pushes.
|
|
120
|
+
*
|
|
121
|
+
* Use it when a plugin needs its own voice persona without owning a skill
|
|
122
|
+
* (e.g. a guide HUD). A solution-wide persona belongs in solution.voice.
|
|
123
|
+
*/
|
|
124
|
+
persona?: VoicePersona;
|
|
125
|
+
/**
|
|
126
|
+
* `'none'` = a CLEAN call: the voice gets none of the workspace's skills — no
|
|
127
|
+
* skill manifest, no skill tools, no platform welcome line, no mid-call
|
|
128
|
+
* skill-state prompt refresh — only the voice basics, this call's persona and
|
|
129
|
+
* the CALLING plugin's own device tools (other solutions' tools registered on
|
|
130
|
+
* the same phone are neither offered nor run). For a plugin that owns its conversation (e.g.
|
|
131
|
+
* a guide HUD). Reduce-only: it can remove what the voice gets, never add.
|
|
132
|
+
* Absent = the workspace's skills, as today. Not combinable with `skillSlug`.
|
|
133
|
+
*/
|
|
134
|
+
skills?: 'none';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The authored persona shape (solution.voice.persona). */
|
|
138
|
+
export interface VoicePersona {
|
|
139
|
+
/** The name the voice uses for itself ("You are <name> …"). */
|
|
140
|
+
name?: string;
|
|
141
|
+
/** Free-text style/behaviour guidance rendered under the identity line. */
|
|
142
|
+
style?: string;
|
|
61
143
|
}
|
|
62
144
|
|
|
63
145
|
/**
|
package/src/tools/loader.ts
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
import { listPlugins, getPlugin, getNativeBundleUrl, getNativeBundleHash } from '../api/plugins';
|
|
14
14
|
import { bundleManager } from '../bundles/bundleManager';
|
|
15
15
|
import { listDeviceTools } from './registry';
|
|
16
|
-
import { getBundleStorage, selectOwnedCacheEntries } from '../bundles/storage';
|
|
16
|
+
import { getBundleStorage, loadCacheIndex, selectOwnedCacheEntries } from '../bundles/storage';
|
|
17
|
+
import { resolvePluginGrants } from '../pluginRuntime';
|
|
17
18
|
import { getTenant } from '@ateam-ai-mobile/core';
|
|
18
19
|
|
|
19
20
|
let inflight: Promise<string[]> | null = null;
|
|
@@ -54,9 +55,9 @@ export async function ensureDeviceToolsLoaded({ force = false } = {}): Promise<s
|
|
|
54
55
|
// a fact about a server process, not about this phone — the bundle we
|
|
55
56
|
// already downloaded still has the tools in it.
|
|
56
57
|
//
|
|
57
|
-
// So fall back to what is on disk
|
|
58
|
-
//
|
|
59
|
-
//
|
|
58
|
+
// So fall back to what is on disk: re-evaluate this tenant's cached
|
|
59
|
+
// bundles under the trusted declaration recorded when a manifest last
|
|
60
|
+
// loaded them (see loadFromDiskCache).
|
|
60
61
|
const recovered = await loadFromDiskCache();
|
|
61
62
|
if (recovered.length > 0) {
|
|
62
63
|
console.log(`[deviceTools] catalog listed none — recovered from cache: ${recovered.join(', ')}`);
|
|
@@ -75,11 +76,14 @@ export async function ensureDeviceToolsLoaded({ force = false } = {}): Promise<s
|
|
|
75
76
|
return;
|
|
76
77
|
}
|
|
77
78
|
try {
|
|
79
|
+
// The manifest is the authority on what the tools may touch — the
|
|
80
|
+
// same grants its rendered surface gets, never the bundle's claim.
|
|
78
81
|
await bundleManager.loadTools(
|
|
79
82
|
m.id,
|
|
80
83
|
bundleUrl,
|
|
81
84
|
m.version || '1.0.0',
|
|
82
85
|
getNativeBundleHash(m) || undefined,
|
|
86
|
+
resolvePluginGrants({ pluginId: m.id, manifest: m }),
|
|
83
87
|
);
|
|
84
88
|
} catch (err: any) {
|
|
85
89
|
console.warn(`[deviceTools] ${m.id} failed to load:`, err?.message || String(err));
|
|
@@ -127,10 +131,20 @@ export async function ensureDeviceToolsLoaded({ force = false } = {}): Promise<s
|
|
|
127
131
|
* An entry with no recorded tenant predates the stamp. Its owner is unknown,
|
|
128
132
|
* which means untrusted — skip it and let the normal catalog path re-download
|
|
129
133
|
* it under a real hash gate.
|
|
134
|
+
*
|
|
135
|
+
* GRANTS COME FROM THE RECORDED MANIFEST, NEVER THE BUNDLE. With no manifest
|
|
136
|
+
* here, the tools register under the `trusted` declaration (pluginId +
|
|
137
|
+
* capabilities + stateDomains) the bundle manager recorded on the entry when a
|
|
138
|
+
* manifest last loaded these bytes. An entry without one — written before the
|
|
139
|
+
* record existed, or never loaded under a manifest — is skipped (fail closed):
|
|
140
|
+
* the only other source is the bundle's own claim, and the catalog path
|
|
141
|
+
* registers it properly once its connector answers.
|
|
130
142
|
*/
|
|
131
143
|
async function loadFromDiskCache(): Promise<string[]> {
|
|
132
144
|
try {
|
|
133
|
-
|
|
145
|
+
// Canonical keys only (loadCacheIndex): an entry an older build filed under
|
|
146
|
+
// the manifest's unencoded spelling is recovered under pluginBundleUrl.
|
|
147
|
+
const index = await loadCacheIndex(getBundleStorage());
|
|
134
148
|
const entries = Object.values(index || {});
|
|
135
149
|
if (entries.length === 0) return [];
|
|
136
150
|
|
|
@@ -145,11 +159,17 @@ async function loadFromDiskCache(): Promise<string[]> {
|
|
|
145
159
|
if (skipped > 0) {
|
|
146
160
|
console.log(`[deviceTools] disk-cache recovery: ${mine.length} for "${tenant}", skipped ${skipped} not owned by it`);
|
|
147
161
|
}
|
|
148
|
-
|
|
162
|
+
const declared = mine.filter((e) => !!e.trusted?.pluginId);
|
|
163
|
+
if (declared.length < mine.length) {
|
|
164
|
+
console.log(
|
|
165
|
+
`[deviceTools] disk-cache recovery: skipped ${mine.length - declared.length} with no recorded manifest grant (fail closed)`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
if (declared.length === 0) return [];
|
|
149
169
|
|
|
150
|
-
await Promise.all(
|
|
170
|
+
await Promise.all(declared.map(async (e) => {
|
|
151
171
|
try {
|
|
152
|
-
await bundleManager.loadTools(
|
|
172
|
+
await bundleManager.loadTools(e.trusted!.pluginId, e.bundleUrl, e.version, e.bundleHash, e.trusted!);
|
|
153
173
|
} catch {
|
|
154
174
|
// A cached bundle that no longer loads is not worth failing a call over.
|
|
155
175
|
}
|
package/src/tools/registry.ts
CHANGED
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { createNativeBridge } from '../bridge/native';
|
|
16
16
|
import { getPluginNative, gatePluginNative } from '../host';
|
|
17
|
-
import { gateDeviceState } from '../deviceStateHost';
|
|
18
|
-
import type { DeviceToolContext, DeviceToolDefinition, NativeCapabilities } from '../sdk/types';
|
|
17
|
+
import { gateDeviceState, withoutSubscribe } from '../deviceStateHost';
|
|
18
|
+
import type { DeviceToolContext, DeviceToolDefinition, DeviceToolFailureReason, NativeCapabilities } from '../sdk/types';
|
|
19
19
|
import type { DeviceBridgeDomain } from '../sdk/device-state';
|
|
20
20
|
|
|
21
21
|
export interface DeviceToolSummary {
|
|
@@ -27,7 +27,7 @@ export interface DeviceToolSummary {
|
|
|
27
27
|
|
|
28
28
|
export type DeviceToolOutcome =
|
|
29
29
|
| { ok: true; result: any }
|
|
30
|
-
| { ok: false; error: string; reason:
|
|
30
|
+
| { ok: false; error: string; reason: DeviceToolFailureReason };
|
|
31
31
|
|
|
32
32
|
interface Entry {
|
|
33
33
|
pluginId: string;
|
|
@@ -62,7 +62,8 @@ export function setDeviceToolContextProvider(
|
|
|
62
62
|
* Default context: the plugin's own bridge + a capability-gated Device Bridge +
|
|
63
63
|
* the L3 DeviceState gated to the domains the plugin declared. All three are the
|
|
64
64
|
* SAME gated objects the plugin gets when it renders — a tool reaches nothing a
|
|
65
|
-
* UI plugin couldn't.
|
|
65
|
+
* UI plugin couldn't. (One thing less: `callDeviceTool` takes `subscribe` away,
|
|
66
|
+
* because a call has no lifetime to own one.)
|
|
66
67
|
*/
|
|
67
68
|
function defaultContext(
|
|
68
69
|
pluginId: string,
|
|
@@ -120,8 +121,11 @@ export function unregisterDeviceTools(pluginId: string): void {
|
|
|
120
121
|
}
|
|
121
122
|
}
|
|
122
123
|
|
|
123
|
-
|
|
124
|
-
|
|
124
|
+
/** Is `name` registered — and, given `pluginId`, registered BY that plugin?
|
|
125
|
+
* (A clean voice call only lets its owner's own tools run.) */
|
|
126
|
+
export function hasDeviceTool(name: string, pluginId?: string): boolean {
|
|
127
|
+
const entry = registry.get(name);
|
|
128
|
+
return !!entry && (pluginId === undefined || entry.pluginId === pluginId);
|
|
125
129
|
}
|
|
126
130
|
|
|
127
131
|
export function listDeviceTools(): DeviceToolSummary[] {
|
|
@@ -152,8 +156,11 @@ export async function callDeviceTool(
|
|
|
152
156
|
|
|
153
157
|
try {
|
|
154
158
|
const build = contextProvider || defaultContext;
|
|
159
|
+
const built = build(entry.pluginId, entry.capabilities, entry.stateDomains);
|
|
155
160
|
const ctx: DeviceToolContext = {
|
|
156
|
-
...
|
|
161
|
+
...built,
|
|
162
|
+
// Whoever built the context, a call cannot own a subscription.
|
|
163
|
+
deviceState: withoutSubscribe(built.deviceState),
|
|
157
164
|
signal: opts.signal,
|
|
158
165
|
};
|
|
159
166
|
|