@ateam-ai-mobile/ui 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -3,7 +3,7 @@ import { Message, JobProgress, UIAction, AdasSession, TenantMembership, HandoffE
3
3
  import * as React$1 from 'react';
4
4
  import React__default, { ReactNode } from 'react';
5
5
  import * as _ateam_ai_mobile_plugin_runtime from '@ateam-ai-mobile/plugin-runtime';
6
- import { PluginSurfaceMeta, PlatformPluginManifest, VoiceState, VoiceTranscriptEntry, VoiceSessionProvider, VoiceStartOptions, VoiceCaller, VoiceSnapshot, VoiceEvent, VoiceSubscription } from '@ateam-ai-mobile/plugin-runtime';
6
+ import { PluginSurfaceMeta, PlatformPluginManifest, VoiceState, VoiceTranscriptEntry, VoiceSessionProvider, VoicePersona, VoiceStartOptions, VoiceCaller, VoiceSnapshot, VoiceEvent, VoiceSubscription } from '@ateam-ai-mobile/plugin-runtime';
7
7
  import * as zustand from 'zustand';
8
8
 
9
9
  interface AdasChatScreenProps {
@@ -394,6 +394,11 @@ interface InternalState {
394
394
  userSpeaking: boolean;
395
395
  /** Epoch ms of the last speaking:false, or null before the first utterance. */
396
396
  lastUserSpeechEndedAt: number | null;
397
+ /** The last device-tool call the model made (name + when), or null. */
398
+ lastDeviceTool: {
399
+ name: string;
400
+ at: number;
401
+ } | null;
397
402
  /** The WebView should be mounted (a session is wanted / live). */
398
403
  mounted: boolean;
399
404
  /** The WebView source, computed ONCE at start() so state changes (transcript,
@@ -402,6 +407,9 @@ interface InternalState {
402
407
  uri: string;
403
408
  } | null;
404
409
  skillSlug?: string;
410
+ /** Per-call persona override ({ name, style }) — sent to the voice page on the
411
+ * session URL and declared on POST /session; the server layers it. */
412
+ persona?: VoicePersona;
405
413
  }
406
414
  declare class VoiceSessionManager implements VoiceSessionProvider {
407
415
  private state;
@@ -419,6 +427,40 @@ declare class VoiceSessionManager implements VoiceSessionProvider {
419
427
  * conversation just because it declared voice.
420
428
  */
421
429
  private owner;
430
+ /** EVERY start() still in its pre-mount awaits (tool preload, token refresh).
431
+ * The session is not 'connecting' yet, so a stop() arriving now would otherwise
432
+ * be an idle no-op — and a start would then mount a call for a screen that has
433
+ * already gone. A set, not one slot: two concurrent starts must BOTH be
434
+ * cancellable. stop() cancels every pending start of its caller (the platform:
435
+ * all of them); another plugin's stop cancels none. */
436
+ private pendingStarts;
437
+ /**
438
+ * The device-tool names Core ALLOWED for this voice session — Core's sanitized
439
+ * allowset, delivered to the phone in a `deviceToolAllowset` message.
440
+ * Authorization gates against THIS (necessary, alongside the registry's can-do).
441
+ *
442
+ * WHY (GPT gate 1, corrected): the phone only PROPOSES its available tools
443
+ * (sendDeviceToolList); CORE is the session authority and decides which are
444
+ * accepted (session.device_tools). Freezing the phone's own proposal would let
445
+ * a tool Core REJECTED still run locally. So the authz set is populated from
446
+ * Core's accepted set, never from listDeviceTools(). Until Core's set arrives
447
+ * this is null and every call FAILS CLOSED. Pinned once per session; the live
448
+ * registry (which drifts via lazy loads / host registration / tenant changes)
449
+ * never feeds it.
450
+ */
451
+ private frozenTools;
452
+ /** The voice `sessionId` the current allowset is BOUND to. Authorization
453
+ * requires it to equal the active session's id (state.sessionId), so a stale
454
+ * allowset from a previous/other session is refused. */
455
+ private frozenSessionId;
456
+ /** A CLEAN call (skills:'none') started by a plugin offers and runs ONLY that
457
+ * plugin's own device tools — not other solutions' tools that happen to be
458
+ * registered on the same phone. null = every registered tool (a normal call,
459
+ * or the platform's). Set by every start(); like `owner`, kept after stop.
460
+ * ONE identity: the caller is the TRUSTED manifest pluginId gateVoice stamps,
461
+ * and the bundleManager registers a plugin's tools under that same id (never
462
+ * the bundle's own short `id`), so exact equality is the whole check. */
463
+ private toolScope;
422
464
  /** The platform (built-in sheet, mic indicator, session host) sees and
423
465
  * controls any session — it is the user's own surface. A plugin caller may
424
466
  * only touch a session it owns. */
@@ -438,10 +480,9 @@ declare class VoiceSessionManager implements VoiceSessionProvider {
438
480
  prompt(input: string, caller?: VoiceCaller): void;
439
481
  /**
440
482
  * Stream a silent observation into the live session — tell the model without
441
- * asking it to speak. The cheap, high-frequency channel (safe once or twice a
442
- * second) behind a future props.voice.observe(); today the host uses it to run
443
- * the real-device stream proof (VOICE_RT_STREAM_DEVICE_PROOF) before the gated
444
- * plugin API is added. Owner-scoped like every other control.
483
+ * asking it to speak. The cheap channel behind the gated props.voice.observe();
484
+ * every item stays in the model's context for the call, so a caller sends only
485
+ * material changes. Owner-scoped like every other control.
445
486
  */
446
487
  observe(text: string, caller?: VoiceCaller): void;
447
488
  getSnapshot(caller?: VoiceCaller): VoiceSnapshot;
@@ -452,7 +493,26 @@ declare class VoiceSessionManager implements VoiceSessionProvider {
452
493
  private buildUri;
453
494
  /** Handle a postMessage from the WebView. `event` is the RN WebView event. */
454
495
  handleMessage(event: any): void;
455
- /** Answer "what device tools do you have?" — the client is the authority. */
496
+ /**
497
+ * Final authorization = Core's frozen allowset ∩ the current valid local
498
+ * registry, bound to the ACTIVE session. All must hold:
499
+ * - a Core allowset arrived and is bound to THIS session (frozenSessionId ===
500
+ * the active state.sessionId) — a missing or stale/wrong-session allowset
501
+ * fails closed;
502
+ * - the tool is IN that allowset (Core approved it); AND
503
+ * - the tool is STILL registered locally right now (removed-after-approval is
504
+ * refused; a lazily-added tool Core never approved is refused) — and, on a
505
+ * clean call, registered by the call's owner.
506
+ * No fallback to the live registry alone, ever.
507
+ */
508
+ private isToolAuthorized;
509
+ /** Tell the mini-app the call was refused — a stated reason, never a silent drop.
510
+ * `notReady` distinguishes a transient "allowset not here yet" (retry) from a
511
+ * final "not allowed in this session". */
512
+ private postDeviceToolRefusal;
513
+ /** PROPOSE the phone's available tools — this is an offer to Core, not the
514
+ * authorization set. Core sanitizes it and returns the accepted set in a
515
+ * `deviceToolAllowset` message, which is what actually gates execution. */
456
516
  private sendDeviceToolList;
457
517
  /** Run a device tool and post the outcome back into the WebView. */
458
518
  private runDeviceTool;
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ import { Message, JobProgress, UIAction, AdasSession, TenantMembership, HandoffE
3
3
  import * as React$1 from 'react';
4
4
  import React__default, { ReactNode } from 'react';
5
5
  import * as _ateam_ai_mobile_plugin_runtime from '@ateam-ai-mobile/plugin-runtime';
6
- import { PluginSurfaceMeta, PlatformPluginManifest, VoiceState, VoiceTranscriptEntry, VoiceSessionProvider, VoiceStartOptions, VoiceCaller, VoiceSnapshot, VoiceEvent, VoiceSubscription } from '@ateam-ai-mobile/plugin-runtime';
6
+ import { PluginSurfaceMeta, PlatformPluginManifest, VoiceState, VoiceTranscriptEntry, VoiceSessionProvider, VoicePersona, VoiceStartOptions, VoiceCaller, VoiceSnapshot, VoiceEvent, VoiceSubscription } from '@ateam-ai-mobile/plugin-runtime';
7
7
  import * as zustand from 'zustand';
8
8
 
9
9
  interface AdasChatScreenProps {
@@ -394,6 +394,11 @@ interface InternalState {
394
394
  userSpeaking: boolean;
395
395
  /** Epoch ms of the last speaking:false, or null before the first utterance. */
396
396
  lastUserSpeechEndedAt: number | null;
397
+ /** The last device-tool call the model made (name + when), or null. */
398
+ lastDeviceTool: {
399
+ name: string;
400
+ at: number;
401
+ } | null;
397
402
  /** The WebView should be mounted (a session is wanted / live). */
398
403
  mounted: boolean;
399
404
  /** The WebView source, computed ONCE at start() so state changes (transcript,
@@ -402,6 +407,9 @@ interface InternalState {
402
407
  uri: string;
403
408
  } | null;
404
409
  skillSlug?: string;
410
+ /** Per-call persona override ({ name, style }) — sent to the voice page on the
411
+ * session URL and declared on POST /session; the server layers it. */
412
+ persona?: VoicePersona;
405
413
  }
406
414
  declare class VoiceSessionManager implements VoiceSessionProvider {
407
415
  private state;
@@ -419,6 +427,40 @@ declare class VoiceSessionManager implements VoiceSessionProvider {
419
427
  * conversation just because it declared voice.
420
428
  */
421
429
  private owner;
430
+ /** EVERY start() still in its pre-mount awaits (tool preload, token refresh).
431
+ * The session is not 'connecting' yet, so a stop() arriving now would otherwise
432
+ * be an idle no-op — and a start would then mount a call for a screen that has
433
+ * already gone. A set, not one slot: two concurrent starts must BOTH be
434
+ * cancellable. stop() cancels every pending start of its caller (the platform:
435
+ * all of them); another plugin's stop cancels none. */
436
+ private pendingStarts;
437
+ /**
438
+ * The device-tool names Core ALLOWED for this voice session — Core's sanitized
439
+ * allowset, delivered to the phone in a `deviceToolAllowset` message.
440
+ * Authorization gates against THIS (necessary, alongside the registry's can-do).
441
+ *
442
+ * WHY (GPT gate 1, corrected): the phone only PROPOSES its available tools
443
+ * (sendDeviceToolList); CORE is the session authority and decides which are
444
+ * accepted (session.device_tools). Freezing the phone's own proposal would let
445
+ * a tool Core REJECTED still run locally. So the authz set is populated from
446
+ * Core's accepted set, never from listDeviceTools(). Until Core's set arrives
447
+ * this is null and every call FAILS CLOSED. Pinned once per session; the live
448
+ * registry (which drifts via lazy loads / host registration / tenant changes)
449
+ * never feeds it.
450
+ */
451
+ private frozenTools;
452
+ /** The voice `sessionId` the current allowset is BOUND to. Authorization
453
+ * requires it to equal the active session's id (state.sessionId), so a stale
454
+ * allowset from a previous/other session is refused. */
455
+ private frozenSessionId;
456
+ /** A CLEAN call (skills:'none') started by a plugin offers and runs ONLY that
457
+ * plugin's own device tools — not other solutions' tools that happen to be
458
+ * registered on the same phone. null = every registered tool (a normal call,
459
+ * or the platform's). Set by every start(); like `owner`, kept after stop.
460
+ * ONE identity: the caller is the TRUSTED manifest pluginId gateVoice stamps,
461
+ * and the bundleManager registers a plugin's tools under that same id (never
462
+ * the bundle's own short `id`), so exact equality is the whole check. */
463
+ private toolScope;
422
464
  /** The platform (built-in sheet, mic indicator, session host) sees and
423
465
  * controls any session — it is the user's own surface. A plugin caller may
424
466
  * only touch a session it owns. */
@@ -438,10 +480,9 @@ declare class VoiceSessionManager implements VoiceSessionProvider {
438
480
  prompt(input: string, caller?: VoiceCaller): void;
439
481
  /**
440
482
  * Stream a silent observation into the live session — tell the model without
441
- * asking it to speak. The cheap, high-frequency channel (safe once or twice a
442
- * second) behind a future props.voice.observe(); today the host uses it to run
443
- * the real-device stream proof (VOICE_RT_STREAM_DEVICE_PROOF) before the gated
444
- * plugin API is added. Owner-scoped like every other control.
483
+ * asking it to speak. The cheap channel behind the gated props.voice.observe();
484
+ * every item stays in the model's context for the call, so a caller sends only
485
+ * material changes. Owner-scoped like every other control.
445
486
  */
446
487
  observe(text: string, caller?: VoiceCaller): void;
447
488
  getSnapshot(caller?: VoiceCaller): VoiceSnapshot;
@@ -452,7 +493,26 @@ declare class VoiceSessionManager implements VoiceSessionProvider {
452
493
  private buildUri;
453
494
  /** Handle a postMessage from the WebView. `event` is the RN WebView event. */
454
495
  handleMessage(event: any): void;
455
- /** Answer "what device tools do you have?" — the client is the authority. */
496
+ /**
497
+ * Final authorization = Core's frozen allowset ∩ the current valid local
498
+ * registry, bound to the ACTIVE session. All must hold:
499
+ * - a Core allowset arrived and is bound to THIS session (frozenSessionId ===
500
+ * the active state.sessionId) — a missing or stale/wrong-session allowset
501
+ * fails closed;
502
+ * - the tool is IN that allowset (Core approved it); AND
503
+ * - the tool is STILL registered locally right now (removed-after-approval is
504
+ * refused; a lazily-added tool Core never approved is refused) — and, on a
505
+ * clean call, registered by the call's owner.
506
+ * No fallback to the live registry alone, ever.
507
+ */
508
+ private isToolAuthorized;
509
+ /** Tell the mini-app the call was refused — a stated reason, never a silent drop.
510
+ * `notReady` distinguishes a transient "allowset not here yet" (retry) from a
511
+ * final "not allowed in this session". */
512
+ private postDeviceToolRefusal;
513
+ /** PROPOSE the phone's available tools — this is an offer to Core, not the
514
+ * authorization set. Core sanitizes it and returns the accepted set in a
515
+ * `deviceToolAllowset` message, which is what actually gates execution. */
456
516
  private sendDeviceToolList;
457
517
  /** Run a device tool and post the outcome back into the WebView. */
458
518
  private runDeviceTool;
package/dist/index.js CHANGED
@@ -2837,6 +2837,7 @@ var IDLE = {
2837
2837
  muted: false,
2838
2838
  userSpeaking: false,
2839
2839
  lastUserSpeechEndedAt: null,
2840
+ lastDeviceTool: null,
2840
2841
  mounted: false,
2841
2842
  webViewSource: null,
2842
2843
  skillSlug: void 0
@@ -2858,6 +2859,40 @@ var VoiceSessionManager = class {
2858
2859
  * conversation just because it declared voice.
2859
2860
  */
2860
2861
  this.owner = null;
2862
+ /** EVERY start() still in its pre-mount awaits (tool preload, token refresh).
2863
+ * The session is not 'connecting' yet, so a stop() arriving now would otherwise
2864
+ * be an idle no-op — and a start would then mount a call for a screen that has
2865
+ * already gone. A set, not one slot: two concurrent starts must BOTH be
2866
+ * cancellable. stop() cancels every pending start of its caller (the platform:
2867
+ * all of them); another plugin's stop cancels none. */
2868
+ this.pendingStarts = /* @__PURE__ */ new Set();
2869
+ /**
2870
+ * The device-tool names Core ALLOWED for this voice session — Core's sanitized
2871
+ * allowset, delivered to the phone in a `deviceToolAllowset` message.
2872
+ * Authorization gates against THIS (necessary, alongside the registry's can-do).
2873
+ *
2874
+ * WHY (GPT gate 1, corrected): the phone only PROPOSES its available tools
2875
+ * (sendDeviceToolList); CORE is the session authority and decides which are
2876
+ * accepted (session.device_tools). Freezing the phone's own proposal would let
2877
+ * a tool Core REJECTED still run locally. So the authz set is populated from
2878
+ * Core's accepted set, never from listDeviceTools(). Until Core's set arrives
2879
+ * this is null and every call FAILS CLOSED. Pinned once per session; the live
2880
+ * registry (which drifts via lazy loads / host registration / tenant changes)
2881
+ * never feeds it.
2882
+ */
2883
+ this.frozenTools = null;
2884
+ /** The voice `sessionId` the current allowset is BOUND to. Authorization
2885
+ * requires it to equal the active session's id (state.sessionId), so a stale
2886
+ * allowset from a previous/other session is refused. */
2887
+ this.frozenSessionId = null;
2888
+ /** A CLEAN call (skills:'none') started by a plugin offers and runs ONLY that
2889
+ * plugin's own device tools — not other solutions' tools that happen to be
2890
+ * registered on the same phone. null = every registered tool (a normal call,
2891
+ * or the platform's). Set by every start(); like `owner`, kept after stop.
2892
+ * ONE identity: the caller is the TRUSTED manifest pluginId gateVoice stamps,
2893
+ * and the bundleManager registers a plugin's tools under that same id (never
2894
+ * the bundle's own short `id`), so exact equality is the whole check. */
2895
+ this.toolScope = null;
2861
2896
  this.injectedTrackHook = INJECT_TRACK_HOOK;
2862
2897
  }
2863
2898
  /** The platform (built-in sheet, mic indicator, session host) sees and
@@ -2893,6 +2928,11 @@ var VoiceSessionManager = class {
2893
2928
  if (!this.canAccess(caller)) throw this.foreignError();
2894
2929
  return this.waitForConnect();
2895
2930
  }
2931
+ if (opts?.skills === "none" && opts?.skillSlug) {
2932
+ throw new Error("voice: skills:'none' (a clean call) cannot also hand the mic to a skill (skillSlug).");
2933
+ }
2934
+ const ticket = { caller, cancelled: false };
2935
+ this.pendingStarts.add(ticket);
2896
2936
  try {
2897
2937
  await (0, import_plugin_runtime13.ensureDeviceToolsLoaded)();
2898
2938
  } catch (e) {
@@ -2902,7 +2942,21 @@ var VoiceSessionManager = class {
2902
2942
  if (tokenNeedsRefresh((0, import_core22.getToken)())) await (0, import_core22.refreshToken)();
2903
2943
  } catch {
2904
2944
  }
2945
+ this.pendingStarts.delete(ticket);
2946
+ if (ticket.cancelled) throw new Error("voice: stopped before the session started");
2947
+ const after = this.getInternal().callState;
2948
+ if (after === "connected") {
2949
+ if (!this.canAccess(caller)) throw this.foreignError();
2950
+ return this.realSnapshot();
2951
+ }
2952
+ if (after === "connecting") {
2953
+ if (!this.canAccess(caller)) throw this.foreignError();
2954
+ return this.waitForConnect();
2955
+ }
2905
2956
  this.owner = caller;
2957
+ this.toolScope = opts?.skills === "none" && typeof caller === "string" ? caller : null;
2958
+ this.frozenTools = null;
2959
+ this.frozenSessionId = null;
2906
2960
  const skillSlug = opts?.skillSlug;
2907
2961
  this.setState({
2908
2962
  callState: "connecting",
@@ -2913,14 +2967,23 @@ var VoiceSessionManager = class {
2913
2967
  muted: false,
2914
2968
  mounted: true,
2915
2969
  skillSlug,
2916
- webViewSource: { uri: this.buildUri(skillSlug) }
2970
+ persona: opts?.persona,
2971
+ webViewSource: { uri: this.buildUri(skillSlug, opts?.persona, opts?.skills) }
2917
2972
  });
2918
2973
  return this.waitForConnect();
2919
2974
  }
2920
2975
  stop(caller = import_plugin_runtime13.PLATFORM_VOICE_CALLER) {
2921
2976
  if (this.state.callState !== "idle" && !this.canAccess(caller)) throw this.foreignError();
2977
+ for (const t of [...this.pendingStarts]) {
2978
+ if (caller === import_plugin_runtime13.PLATFORM_VOICE_CALLER || caller === t.caller) {
2979
+ t.cancelled = true;
2980
+ this.pendingStarts.delete(t);
2981
+ }
2982
+ }
2922
2983
  this.webView?.injectJavaScript?.("window.hangUp && window.hangUp(); true;");
2923
2984
  this.rejectPending(new Error("voice: session stopped before it connected"));
2985
+ this.frozenTools = null;
2986
+ this.frozenSessionId = null;
2924
2987
  this.setState({
2925
2988
  ...IDLE,
2926
2989
  // keep the transcript of the call that just ended for any reader still up
@@ -2941,10 +3004,9 @@ var VoiceSessionManager = class {
2941
3004
  }
2942
3005
  /**
2943
3006
  * Stream a silent observation into the live session — tell the model without
2944
- * asking it to speak. The cheap, high-frequency channel (safe once or twice a
2945
- * second) behind a future props.voice.observe(); today the host uses it to run
2946
- * the real-device stream proof (VOICE_RT_STREAM_DEVICE_PROOF) before the gated
2947
- * plugin API is added. Owner-scoped like every other control.
3007
+ * asking it to speak. The cheap channel behind the gated props.voice.observe();
3008
+ * every item stays in the model's context for the call, so a caller sends only
3009
+ * material changes. Owner-scoped like every other control.
2948
3010
  */
2949
3011
  observe(text, caller = import_plugin_runtime13.PLATFORM_VOICE_CALLER) {
2950
3012
  if (this.state.callState !== "connected") {
@@ -2966,6 +3028,8 @@ var VoiceSessionManager = class {
2966
3028
  listening: !!s.userPartial,
2967
3029
  userSpeaking: s.userSpeaking,
2968
3030
  lastUserSpeechEndedAt: s.lastUserSpeechEndedAt,
3031
+ lastDeviceTool: s.lastDeviceTool,
3032
+ authorizedSessionId: this.frozenSessionId,
2969
3033
  lastError: s.error
2970
3034
  };
2971
3035
  }
@@ -2978,6 +3042,8 @@ var VoiceSessionManager = class {
2978
3042
  listening: false,
2979
3043
  userSpeaking: false,
2980
3044
  lastUserSpeechEndedAt: null,
3045
+ lastDeviceTool: null,
3046
+ authorizedSessionId: null,
2981
3047
  lastError: null
2982
3048
  };
2983
3049
  }
@@ -2994,12 +3060,15 @@ var VoiceSessionManager = class {
2994
3060
  return this.canAccess(caller) ? this.state.transcript.slice() : [];
2995
3061
  }
2996
3062
  // ── WebView plumbing ─────────────────────────────────────────
2997
- buildUri(skillSlug) {
3063
+ buildUri(skillSlug, persona, skills) {
2998
3064
  const token = (0, import_core22.getToken)();
2999
3065
  const tenant = (0, import_core22.getTenant)();
3000
3066
  const actorId = (0, import_core22.getUser)()?.actorId || "";
3001
3067
  const skillParam = skillSlug ? `&skillSlug=${encodeURIComponent(skillSlug)}` : "";
3002
- return `${(0, import_core22.getVoiceApiUrl)()}/app?mode=mini&embedded=rn&authToken=${encodeURIComponent(token || "")}&tenant=${encodeURIComponent(tenant)}&actorId=${encodeURIComponent(actorId)}${skillParam}`;
3068
+ const p = persona && (persona.name || persona.style) ? { ...persona.name ? { name: persona.name } : {}, ...persona.style ? { style: persona.style } : {} } : null;
3069
+ const personaParam = p ? `&persona=${encodeURIComponent(JSON.stringify(p))}` : "";
3070
+ const skillsParam = skills === "none" ? "&skills=none" : "";
3071
+ return `${(0, import_core22.getVoiceApiUrl)()}/app?mode=mini&embedded=rn&authToken=${encodeURIComponent(token || "")}&tenant=${encodeURIComponent(tenant)}&actorId=${encodeURIComponent(actorId)}${skillParam}${personaParam}${skillsParam}`;
3003
3072
  }
3004
3073
  /** Handle a postMessage from the WebView. `event` is the RN WebView event. */
3005
3074
  handleMessage(event) {
@@ -3063,6 +3132,27 @@ var VoiceSessionManager = class {
3063
3132
  this.sendDeviceToolList(data.correlationId);
3064
3133
  break;
3065
3134
  }
3135
+ // CORE's ALLOWSET for this session — what the model may ASK for. Core sends
3136
+ // it (even empty, on purpose) at session start. It is a NECESSARY gate, not
3137
+ // the whole story: a call must be in the allowset AND the device must
3138
+ // actually have the tool (the registry check inside callDeviceTool) — Core
3139
+ // says what may be asked, the device decides what it will do.
3140
+ //
3141
+ // Origin-checked (only the voice app may set it) and pinned ONCE per session
3142
+ // (a later message cannot widen it). An EMPTY allowset (arrived) refuses
3143
+ // everything but is distinct from NEVER-arrived (frozenTools stays null);
3144
+ // both fail closed here — we never fall back to the phone's own registry,
3145
+ // which is the seam this closes.
3146
+ case "deviceToolAllowset": {
3147
+ const sender = originOf(event?.nativeEvent?.url);
3148
+ const expected = originOf((0, import_core22.getVoiceApiUrl)());
3149
+ if (!sender || !expected || sender !== expected) break;
3150
+ if (Array.isArray(data.tools) && typeof data.sessionId === "string" && data.sessionId !== this.frozenSessionId) {
3151
+ this.frozenTools = new Set(data.tools.filter((n) => typeof n === "string"));
3152
+ this.frozenSessionId = data.sessionId;
3153
+ }
3154
+ break;
3155
+ }
3066
3156
  case "device-tool-call": {
3067
3157
  const sender = originOf(event?.nativeEvent?.url);
3068
3158
  const expected = originOf((0, import_core22.getVoiceApiUrl)());
@@ -3070,14 +3160,64 @@ var VoiceSessionManager = class {
3070
3160
  console.warn(`[voiceSession] REFUSED device tool call from "${sender || "unknown origin"}" (expected "${expected}")`);
3071
3161
  break;
3072
3162
  }
3163
+ if (data.name) this.setState({ lastDeviceTool: { name: data.name, at: Date.now() } });
3164
+ if (!this.isToolAuthorized(data.name)) {
3165
+ const notReady = this.frozenTools === null;
3166
+ console.warn(`[voiceSession] REFUSED "${data.name}" \u2014 ${notReady ? "allowset not received yet" : "not authorized for this session"}`);
3167
+ this.postDeviceToolRefusal(data.correlationId, data.name, notReady);
3168
+ break;
3169
+ }
3073
3170
  void this.runDeviceTool(data);
3074
3171
  break;
3075
3172
  }
3076
3173
  }
3077
3174
  }
3078
- /** Answer "what device tools do you have?" — the client is the authority. */
3175
+ /**
3176
+ * Final authorization = Core's frozen allowset ∩ the current valid local
3177
+ * registry, bound to the ACTIVE session. All must hold:
3178
+ * - a Core allowset arrived and is bound to THIS session (frozenSessionId ===
3179
+ * the active state.sessionId) — a missing or stale/wrong-session allowset
3180
+ * fails closed;
3181
+ * - the tool is IN that allowset (Core approved it); AND
3182
+ * - the tool is STILL registered locally right now (removed-after-approval is
3183
+ * refused; a lazily-added tool Core never approved is refused) — and, on a
3184
+ * clean call, registered by the call's owner.
3185
+ * No fallback to the live registry alone, ever.
3186
+ */
3187
+ isToolAuthorized(name) {
3188
+ if (typeof name !== "string") return false;
3189
+ if (this.frozenTools === null || this.frozenSessionId === null) return false;
3190
+ if (this.frozenSessionId !== this.state.sessionId) return false;
3191
+ if (!this.frozenTools.has(name)) return false;
3192
+ return (0, import_plugin_runtime13.hasDeviceTool)(name, this.toolScope ?? void 0);
3193
+ }
3194
+ /** Tell the mini-app the call was refused — a stated reason, never a silent drop.
3195
+ * `notReady` distinguishes a transient "allowset not here yet" (retry) from a
3196
+ * final "not allowed in this session". */
3197
+ postDeviceToolRefusal(correlationId, name, notReady) {
3198
+ const payload = notReady ? {
3199
+ correlationId,
3200
+ ok: false,
3201
+ reason: "not_ready",
3202
+ error: "The voice session is still starting up \u2014 its tool allowset has not arrived yet. Try again in a moment."
3203
+ } : {
3204
+ correlationId,
3205
+ ok: false,
3206
+ reason: "not_authorized",
3207
+ error: `"${name}" is not available in this voice session.`
3208
+ };
3209
+ this.webView?.injectJavaScript?.(
3210
+ `window.__adasDeviceToolResult && window.__adasDeviceToolResult(${JSON.stringify(
3211
+ JSON.stringify(payload)
3212
+ )}); true;`
3213
+ );
3214
+ }
3215
+ /** PROPOSE the phone's available tools — this is an offer to Core, not the
3216
+ * authorization set. Core sanitizes it and returns the accepted set in a
3217
+ * `deviceToolAllowset` message, which is what actually gates execution. */
3079
3218
  sendDeviceToolList(correlationId) {
3080
- const tools = (0, import_plugin_runtime13.listDeviceTools)().map((t) => ({
3219
+ const scope = this.toolScope;
3220
+ const tools = (0, import_plugin_runtime13.listDeviceTools)().filter((t) => scope === null || t.pluginId === scope).map((t) => ({
3081
3221
  name: t.name,
3082
3222
  description: t.description,
3083
3223
  input_schema: t.input_schema
@@ -3155,6 +3295,10 @@ var VoiceSessionManager = class {
3155
3295
  this.rejectPending(new Error("voice: reset"));
3156
3296
  this.state = { ...IDLE };
3157
3297
  this.owner = null;
3298
+ this.toolScope = null;
3299
+ this.pendingStarts.clear();
3300
+ this.frozenTools = null;
3301
+ this.frozenSessionId = null;
3158
3302
  this.listeners.clear();
3159
3303
  this.webView = null;
3160
3304
  }