@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,107 @@
1
+ /**
2
+ * Ambient device-tool registration, driven by the auth session (R14).
3
+ *
4
+ * The LOCAL reasoning path must be live BEFORE voice opens — otherwise the only
5
+ * proof of a loadable external skill is a voice-triggered one. So the host wires
6
+ * this to its EXISTING auth-session subscription (no new transport, no plugin
7
+ * special-case): a tenant becoming authenticated loads that tenant's external
8
+ * device-tool bundles (and re-ensures the static platform tools); a tenant
9
+ * SWITCH is a change of tenant while still authenticated, so it clears the old
10
+ * tenant's tools and reloads; sign-out resets the loader latch (the registry
11
+ * itself is cleared by the host's logout teardown, which already calls
12
+ * clearDeviceTools()).
13
+ *
14
+ * The decision is a PURE function so the lifecycle is testable without a network
15
+ * or a device; the effects are injected (same DI shape as the host's logout
16
+ * teardown) so a test drives them with fakes.
17
+ */
18
+ import { registerPlatformDeviceTools } from './platformTools';
19
+ import { clearDeviceTools } from './registry';
20
+ // NOTE: `./loader` pulls in the bundle manager (react-native, core), so it is
21
+ // imported LAZILY inside the real effects below — keeping this module itself
22
+ // node-testable (a test injects fake effects and never touches that graph).
23
+
24
+ export interface SessionSnapshot {
25
+ authenticated: boolean;
26
+ /** The tenant the session is scoped to (derive it; never a stored copy). */
27
+ tenant: string | null;
28
+ }
29
+
30
+ export type DeviceToolsAction = 'load' | 'reset' | 'none';
31
+
32
+ /**
33
+ * PURE: what should the device-tool registry do for this session transition?
34
+ * - a new authenticated tenant (initial sign-in OR a switch) → 'load'
35
+ * - the same authenticated tenant again (e.g. a silent token refresh) → 'none'
36
+ * - leaving authenticated (sign-out) → 'reset'
37
+ */
38
+ export function deviceToolsActionForSession(
39
+ next: SessionSnapshot,
40
+ prevTenant: string | null,
41
+ ): { action: DeviceToolsAction; tenant: string | null } {
42
+ const tenant = next.authenticated ? (next.tenant ?? null) : null;
43
+ if (tenant && tenant !== prevTenant) return { action: 'load', tenant };
44
+ if (!tenant && prevTenant) return { action: 'reset', tenant: null };
45
+ return { action: 'none', tenant: prevTenant };
46
+ }
47
+
48
+ /** The effects a session transition drives — injected so a test can fake them. */
49
+ export interface DeviceToolsSessionDeps {
50
+ /** Drop the previous tenant's device tools (a switch does not run logout teardown). */
51
+ clear: () => void;
52
+ /** Ensure the static, host-owned platform device tools are present. */
53
+ registerPlatform: () => void;
54
+ /** Forget the "already loaded" latch so a later (non-forced) load runs — sign-out. */
55
+ resetLoader: () => void;
56
+ /** Fire-and-forget reload of the current tenant's external device-tool bundles. */
57
+ load: () => void;
58
+ }
59
+
60
+ const REAL_DEPS: DeviceToolsSessionDeps = {
61
+ clear: clearDeviceTools,
62
+ registerPlatform: registerPlatformDeviceTools,
63
+ // `./loader` reaches react-native via the bundle manager; import it only when
64
+ // an effect actually fires (real app), never at module load (tests).
65
+ resetLoader: () => {
66
+ void import('./loader').then((m) => m.resetDeviceToolLoader());
67
+ },
68
+ load: () => {
69
+ void import('./loader').then((m) => {
70
+ m.resetDeviceToolLoader();
71
+ return m.ensureDeviceToolsLoaded({ force: true });
72
+ });
73
+ },
74
+ };
75
+
76
+ let prevTenant: string | null = null;
77
+
78
+ /**
79
+ * Apply a session transition to the device-tool registry. Call from the host's
80
+ * existing auth-session subscription with a derived snapshot. Idempotent per
81
+ * tenant: repeated snapshots for the same authenticated tenant do nothing, so a
82
+ * silent token refresh does not churn the registry. Returns the action taken.
83
+ */
84
+ export function syncDeviceToolsForSession(
85
+ next: SessionSnapshot,
86
+ deps: DeviceToolsSessionDeps = REAL_DEPS,
87
+ ): DeviceToolsAction {
88
+ const { action, tenant } = deviceToolsActionForSession(next, prevTenant);
89
+ prevTenant = tenant;
90
+ if (action === 'load') {
91
+ // A new authenticated tenant: drop the prior tenant's tools, re-seat the
92
+ // static platform tools, then reload this tenant's external bundles (forced).
93
+ deps.clear();
94
+ deps.registerPlatform();
95
+ deps.load();
96
+ } else if (action === 'reset') {
97
+ // Sign-out: the host's logout teardown clears the registry; we only forget
98
+ // the loader latch so a later sign-in reloads.
99
+ deps.resetLoader();
100
+ }
101
+ return action;
102
+ }
103
+
104
+ /** Test seam: forget the last-seen tenant so a test starts from signed-out. */
105
+ export function __resetSessionSyncForTest(): void {
106
+ prevTenant = null;
107
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Host DI + gate for `props.voice` — the session counterpart of
3
+ * setDeviceStateProvider / setPluginNativeProvider.
4
+ *
5
+ * The app mounts one live voice session (VoiceSessionHost in @ateam-ai-mobile/ui)
6
+ * and hands it here once at startup via setVoiceProvider. gateVoice then decides,
7
+ * per plugin, whether that session is exposed:
8
+ *
9
+ * - declared (`capabilities: { voice: true }`) + a provider present → the real
10
+ * session.
11
+ * - declared but no provider (no session host in this build) → a refusal that
12
+ * names the reason.
13
+ * - NOT declared → a refusal that names the reason.
14
+ *
15
+ * ABSENCE IS AN ANSWER (spec invariant 6). A denied surface never fakes an
16
+ * `idle` snapshot and never silently no-ops: reads report `state: 'error'` with
17
+ * an explicit `lastError`, and actions throw. A silent `idle` would read to a
18
+ * reasoning loop as "a working session that simply isn't connected yet" — a
19
+ * fabrication, the same class of bug as an undefined that flows into an answer.
20
+ */
21
+ import type { VoiceAPI, VoiceSnapshot, VoiceSessionProvider } from './sdk/voice';
22
+
23
+ let _provider: VoiceSessionProvider | null = null;
24
+
25
+ /** Host DI: the app supplies its live (owner-aware) voice session. Called once
26
+ * at startup; pass null to detach (e.g. in a build with no voice). */
27
+ export function setVoiceProvider(provider: VoiceSessionProvider | null): void {
28
+ _provider = provider || null;
29
+ }
30
+
31
+ /** Whether this build has a voice session to expose — for diagnostics. */
32
+ export function implementedVoice(): boolean {
33
+ return !!_provider;
34
+ }
35
+
36
+ function voiceDenial(why: string): Error {
37
+ const err: any = new Error(`voice: ${why}`);
38
+ // Frozen vocabulary (matches gateDeviceState): a capability/manifest gate IS a
39
+ // policy, so a denial is RESTRICTED — no new error vocabulary.
40
+ err.code = 'RESTRICTED';
41
+ return err;
42
+ }
43
+
44
+ /**
45
+ * The refused surface. Reads answer honestly ("I am not available, here is
46
+ * why"); actions throw. Deliberately NOT a fake `idle` and NOT a silent no-op.
47
+ */
48
+ function deniedVoice(why: string): VoiceAPI {
49
+ const fail = () => { throw voiceDenial(why); };
50
+ return {
51
+ async start() { return fail(); },
52
+ stop() { fail(); },
53
+ setMuted() { fail(); },
54
+ prompt() { fail(); },
55
+ observe() { fail(); },
56
+ getSnapshot(): VoiceSnapshot {
57
+ return {
58
+ state: 'error',
59
+ sessionId: null,
60
+ muted: false,
61
+ speaking: false,
62
+ listening: false,
63
+ userSpeaking: false,
64
+ lastUserSpeechEndedAt: null,
65
+ lastDeviceTool: null,
66
+ authorizedSessionId: null,
67
+ lastError: why,
68
+ };
69
+ },
70
+ subscribe() { return fail(); },
71
+ transcript() { return []; },
72
+ };
73
+ }
74
+
75
+ /**
76
+ * A per-plugin VoiceAPI that stamps `pluginId` as the caller on every call, so
77
+ * the owner-aware provider can isolate it. TWO plugins get TWO facades bound to
78
+ * TWO identities; neither can read, control, or attach to the other's live
79
+ * session — even though both declared voice and both share the one physical
80
+ * session. A plugin never supplies its own identity: it is captured here.
81
+ */
82
+ function scopedVoice(provider: VoiceSessionProvider, pluginId: string): VoiceAPI {
83
+ return {
84
+ start: (opts) => provider.start(opts, pluginId),
85
+ stop: () => provider.stop(pluginId),
86
+ setMuted: (muted) => provider.setMuted(muted, pluginId),
87
+ prompt: (input) => provider.prompt(input, pluginId),
88
+ observe: (text) => provider.observe(text, pluginId),
89
+ getSnapshot: () => provider.getSnapshot(pluginId),
90
+ subscribe: (cb) => provider.subscribe(cb, pluginId),
91
+ transcript: () => provider.transcript(pluginId),
92
+ };
93
+ }
94
+
95
+ /**
96
+ * The `voice` a plugin receives, gated by whether it declared the capability AND
97
+ * scoped to its identity.
98
+ *
99
+ * `declared` is `!!capabilities.voice` from the plugin's TRUSTED declaration
100
+ * (resolved in pluginRuntime.ts — the server manifest for a remote plugin, the
101
+ * registry entry for a host-owned one). A downloaded bundle cannot grant itself
102
+ * voice. `pluginId` is the trusted identity the returned facade is bound to.
103
+ *
104
+ * Reading the transcript of a live session is a privacy surface, not just an
105
+ * API: this gate is the only thing between a plugin and everything the user
106
+ * says. Two protections stack: (1) it is coupled to evidence — a live session
107
+ * shows the platform-owned, unsuppressable mic indicator, so there is no path to
108
+ * a transcript without visible proof the mic is/was live; and (2) it is scoped
109
+ * to the owner, so declaring `voice` lets a plugin run ITS OWN conversation, not
110
+ * eavesdrop on another solution's. A missing identity FAILS CLOSED.
111
+ */
112
+ export function gateVoice(declared: boolean, pluginId: string): VoiceAPI {
113
+ if (!declared) {
114
+ return deniedVoice(
115
+ "not declared in this plugin's capabilities. Add `capabilities: { voice: true }` to use it.",
116
+ );
117
+ }
118
+ if (!pluginId) {
119
+ return deniedVoice(
120
+ 'no plugin identity — voice fails closed without a caller to scope the session to.',
121
+ );
122
+ }
123
+ return _provider
124
+ ? scopedVoice(_provider, pluginId)
125
+ : deniedVoice(
126
+ 'no voice session in this build — the app has not mounted a VoiceSessionHost / called setVoiceProvider.',
127
+ );
128
+ }