@vellumai/assistant 0.5.4 → 0.5.5

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.
Files changed (59) hide show
  1. package/Dockerfile +18 -27
  2. package/node_modules/@vellumai/ces-contracts/src/index.ts +1 -0
  3. package/node_modules/@vellumai/ces-contracts/src/trust-rules.ts +42 -0
  4. package/package.json +1 -1
  5. package/src/__tests__/credential-security-invariants.test.ts +2 -0
  6. package/src/__tests__/openai-whisper.test.ts +93 -0
  7. package/src/__tests__/slack-messaging-token-resolution.test.ts +319 -0
  8. package/src/__tests__/volume-security-guard.test.ts +155 -0
  9. package/src/config/bundled-skills/messaging/tools/shared.ts +1 -0
  10. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +16 -37
  11. package/src/config/env-registry.ts +9 -0
  12. package/src/config/feature-flag-registry.json +8 -0
  13. package/src/credential-execution/managed-catalog.ts +5 -15
  14. package/src/daemon/config-watcher.ts +4 -1
  15. package/src/daemon/daemon-control.ts +7 -0
  16. package/src/daemon/lifecycle.ts +7 -1
  17. package/src/daemon/providers-setup.ts +2 -1
  18. package/src/hooks/manager.ts +7 -0
  19. package/src/instrument.ts +33 -1
  20. package/src/memory/embedding-local.ts +11 -5
  21. package/src/memory/job-handlers/conversation-starters.ts +24 -36
  22. package/src/messaging/provider.ts +9 -0
  23. package/src/messaging/providers/slack/adapter.ts +29 -2
  24. package/src/oauth/connection-resolver.test.ts +22 -18
  25. package/src/oauth/connection-resolver.ts +92 -7
  26. package/src/oauth/platform-connection.test.ts +78 -69
  27. package/src/oauth/platform-connection.ts +12 -19
  28. package/src/permissions/trust-client.ts +343 -0
  29. package/src/permissions/trust-store-interface.ts +105 -0
  30. package/src/permissions/trust-store.ts +523 -36
  31. package/src/platform/client.test.ts +148 -0
  32. package/src/platform/client.ts +71 -0
  33. package/src/providers/speech-to-text/openai-whisper.test.ts +190 -0
  34. package/src/providers/speech-to-text/openai-whisper.ts +68 -0
  35. package/src/providers/speech-to-text/resolve.ts +9 -0
  36. package/src/providers/speech-to-text/types.ts +17 -0
  37. package/src/runtime/http-server.ts +2 -2
  38. package/src/runtime/routes/inbound-message-handler.ts +27 -3
  39. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +16 -1
  40. package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +287 -0
  41. package/src/runtime/routes/inbound-stages/transcribe-audio.ts +122 -0
  42. package/src/runtime/routes/log-export-routes.ts +1 -0
  43. package/src/runtime/routes/secret-routes.ts +4 -1
  44. package/src/security/ces-credential-client.ts +173 -0
  45. package/src/security/secure-keys.ts +65 -22
  46. package/src/signals/bash.ts +3 -0
  47. package/src/signals/cancel.ts +3 -0
  48. package/src/signals/confirm.ts +3 -0
  49. package/src/signals/conversation-undo.ts +3 -0
  50. package/src/signals/event-stream.ts +7 -0
  51. package/src/signals/shotgun.ts +3 -0
  52. package/src/signals/trust-rule.ts +3 -0
  53. package/src/telemetry/usage-telemetry-reporter.test.ts +23 -36
  54. package/src/telemetry/usage-telemetry-reporter.ts +21 -19
  55. package/src/util/device-id.ts +70 -7
  56. package/src/util/logger.ts +35 -9
  57. package/src/util/platform.ts +29 -5
  58. package/src/workspace/migrations/migrate-to-workspace-volume.ts +113 -0
  59. package/src/workspace/migrations/registry.ts +2 -0
package/Dockerfile CHANGED
@@ -47,57 +47,48 @@ RUN apt-get update && apt-get install -y \
47
47
  g++ \
48
48
  git \
49
49
  sudo \
50
+ bubblewrap \
51
+ htop \
52
+ procps \
50
53
  && rm -rf /var/lib/apt/lists/*
51
54
 
55
+ ## Alias bwrap to bubblewrap
56
+ RUN ln -sf /usr/bin/bwrap /usr/bin/bubblewrap
57
+
52
58
  # Copy bun binary from builder instead of re-installing
53
59
  COPY --from=builder /root/.bun/bin/bun /usr/local/bin/bun
54
60
  RUN ln -sf /usr/local/bin/bun /usr/local/bin/bunx
55
61
 
62
+ # Install assistant CLI launcher backed by the bundled assistant package
63
+ RUN printf '#!/usr/bin/env sh\nexec bun run /app/assistant/src/index.ts "$@"\n' > /usr/local/bin/assistant && \
64
+ chmod +x /usr/local/bin/assistant
65
+
56
66
  # Create non-root user that also has sudo access so it can like install stuff
57
67
  RUN groupadd --system --gid 1001 assistant && \
58
68
  useradd --system --uid 1001 --gid assistant --create-home --shell /bin/bash assistant && \
59
69
  echo "assistant ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers
60
70
 
61
- # Set up data directory
62
- RUN mkdir -p /home/assistant/.vellum /data && \
63
- chown -R assistant:assistant /home/assistant/.vellum /data && \
64
- chmod a+rwx /data
71
+ # Set up assistant home directory for local state (device.json, etc.)
72
+ RUN mkdir -p /home/assistant/.vellum && \
73
+ chown -R assistant:assistant /home/assistant/.vellum
65
74
 
66
75
  # Update PATH for assistant user
67
- ENV PATH="/home/assistant/.bun/bin:/data/bin:${PATH}"
76
+ ENV PATH="/home/assistant/.bun/bin:${PATH}"
68
77
 
69
- # Configure package managers to use /data
70
- ENV BUN_INSTALL="/data/.bun"
78
+ # Configure package managers to use assistant home
79
+ ENV BUN_INSTALL="/home/assistant/.bun"
71
80
  ENV PATH="${BUN_INSTALL}/bin:${PATH}"
72
- ENV PYTHONUSERBASE="/data/.python"
81
+ ENV PYTHONUSERBASE="/home/assistant/.python"
73
82
  ENV PATH="${PYTHONUSERBASE}/bin:${PATH}"
74
83
 
75
- # Configure apt/dpkg to install future packages to /data
76
- RUN mkdir -p /data/dpkg/info /data/dpkg/updates /data/dpkg/triggers && \
77
- mkdir -p /data/usr/bin /data/usr/lib /data/usr/share && \
78
- chown -R assistant:assistant /data/dpkg /data/usr
79
-
80
- # Create dpkg configuration for using /data as install prefix
81
- RUN echo 'Dir::State "/data/dpkg";' > /etc/apt/apt.conf.d/99data-dir && \
82
- echo 'Dir::State::status "/data/dpkg/status";' >> /etc/apt/apt.conf.d/99data-dir && \
83
- echo 'Dir::Cache "/data/apt/cache";' >> /etc/apt/apt.conf.d/99data-dir && \
84
- echo 'DPkg::Options {"--instdir=/data/usr";"--admindir=/data/dpkg";"--force-not-root";"--force-bad-path";};' >> /etc/apt/apt.conf.d/99data-dir && \
85
- mkdir -p /data/apt/cache && \
86
- touch /data/dpkg/status && \
87
- chown -R assistant:assistant /data/apt /data/dpkg
88
-
89
- ENV PATH="/data/usr/bin:/data/usr/sbin:${PATH}"
90
- ENV LD_LIBRARY_PATH="/data/usr/lib:/data/usr/lib/x86_64-linux-gnu:/data/usr/lib/aarch64-linux-gnu"
91
-
92
84
  # Ensure the CES bootstrap socket volume is writable by the non-root CES user.
93
85
  RUN mkdir -p /run/ces-bootstrap && chmod 777 /run/ces-bootstrap
94
86
 
95
- USER root
87
+ USER assistant
96
88
 
97
89
  EXPOSE 3001
98
90
 
99
91
  ENV RUNTIME_HTTP_PORT=3001
100
- ENV BASE_DATA_DIR=/data
101
92
  ENV IS_CONTAINERIZED=true
102
93
 
103
94
  # Copy from builder
@@ -145,3 +145,4 @@ export * from "./handles.js";
145
145
  export * from "./grants.js";
146
146
  export * from "./rpc.js";
147
147
  export * from "./rendering.js";
148
+ export * from "./trust-rules.js";
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Trust rule types shared between the assistant daemon and the gateway.
3
+ *
4
+ * These are extracted from `assistant/src/permissions/types.ts` and
5
+ * `assistant/src/permissions/trust-store.ts` so that both packages can
6
+ * reference a single canonical definition.
7
+ */
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Trust decision
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /** The possible decisions a trust rule can make. */
14
+ export type TrustDecision = "allow" | "deny" | "ask";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Trust rule
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export interface TrustRule {
21
+ id: string;
22
+ tool: string;
23
+ pattern: string;
24
+ scope: string;
25
+ decision: TrustDecision;
26
+ priority: number;
27
+ createdAt: number;
28
+ executionTarget?: string;
29
+ allowHighRisk?: boolean;
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Trust file (on-disk shape)
34
+ // ---------------------------------------------------------------------------
35
+
36
+ /** Shape of the `trust.json` file persisted to disk. */
37
+ export interface TrustFileData {
38
+ version: number;
39
+ rules: TrustRule[];
40
+ /** Set to true when the user explicitly accepts the starter approval bundle. */
41
+ starterBundleAccepted?: boolean;
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
@@ -222,6 +222,7 @@ describe("Invariant 2: no generic plaintext secret read API", () => {
222
222
  "messaging/providers/telegram-bot/adapter.ts", // Telegram bot token lookup for connectivity check
223
223
  "runtime/channel-readiness-service.ts", // channel readiness probes for Telegram connectivity
224
224
  "messaging/providers/whatsapp/adapter.ts", // WhatsApp credential lookup for connectivity check
225
+ "messaging/providers/slack/adapter.ts", // Slack bot token lookup for Socket Mode connectivity check
225
226
  "daemon/handlers/config-slack-channel.ts", // Slack channel config credential management
226
227
  "providers/managed-proxy/context.ts", // managed proxy API key lookup for provider initialization
227
228
  "mcp/mcp-oauth-provider.ts", // MCP OAuth token/client/discovery persistence
@@ -254,6 +255,7 @@ describe("Invariant 2: no generic plaintext secret read API", () => {
254
255
  "config/bundled-skills/slack/tools/shared.ts", // Slack skill bot token lookup
255
256
  "daemon/conversation-process.ts", // masked provider key display
256
257
  "daemon/handlers/config-model.ts", // masked provider key display
258
+ "providers/speech-to-text/resolve.ts", // STT provider API key lookup
257
259
  ]);
258
260
 
259
261
  const thisDir = dirname(fileURLToPath(import.meta.url));
@@ -0,0 +1,93 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+
3
+ import { OpenAIWhisperProvider } from "../providers/speech-to-text/openai-whisper.js";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Mock fetch — capture outgoing FormData so we can assert filenames
7
+ // ---------------------------------------------------------------------------
8
+
9
+ const originalFetch = globalThis.fetch;
10
+
11
+ let capturedFormData: FormData | null = null;
12
+
13
+ function mockFetch(
14
+ _url: string | URL | Request,
15
+ init?: RequestInit,
16
+ ): Promise<Response> {
17
+ if (init?.body instanceof FormData) {
18
+ capturedFormData = init.body;
19
+ }
20
+ return Promise.resolve(
21
+ new Response(JSON.stringify({ text: "hello world" }), {
22
+ status: 200,
23
+ headers: { "Content-Type": "application/json" },
24
+ }),
25
+ );
26
+ }
27
+
28
+ beforeEach(() => {
29
+ capturedFormData = null;
30
+ globalThis.fetch = mockFetch as typeof fetch;
31
+ });
32
+
33
+ afterEach(() => {
34
+ globalThis.fetch = originalFetch;
35
+ });
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Tests
39
+ // ---------------------------------------------------------------------------
40
+
41
+ describe("OpenAIWhisperProvider", () => {
42
+ const provider = new OpenAIWhisperProvider("test-api-key");
43
+ const dummyAudio = Buffer.from("fake-audio-data");
44
+
45
+ describe("extensionFromMime (via transcribe filename)", () => {
46
+ test("plain MIME type resolves to correct extension", async () => {
47
+ await provider.transcribe(dummyAudio, "audio/ogg");
48
+ const file = capturedFormData?.get("file") as File;
49
+ expect(file.name).toBe("audio.ogg");
50
+ });
51
+
52
+ test("MIME type with parameters resolves to correct extension", async () => {
53
+ await provider.transcribe(dummyAudio, "audio/ogg; codecs=opus");
54
+ const file = capturedFormData?.get("file") as File;
55
+ expect(file.name).toBe("audio.ogg");
56
+ });
57
+
58
+ test("MIME type with extra whitespace around parameters", async () => {
59
+ await provider.transcribe(dummyAudio, "audio/mpeg ; bitrate=128");
60
+ const file = capturedFormData?.get("file") as File;
61
+ expect(file.name).toBe("audio.mp3");
62
+ });
63
+
64
+ test("unknown MIME type falls back to .audio", async () => {
65
+ await provider.transcribe(dummyAudio, "audio/unknown-format");
66
+ const file = capturedFormData?.get("file") as File;
67
+ expect(file.name).toBe("audio.audio");
68
+ });
69
+
70
+ test("unknown MIME type with parameters still falls back", async () => {
71
+ await provider.transcribe(dummyAudio, "audio/unknown; foo=bar");
72
+ const file = capturedFormData?.get("file") as File;
73
+ expect(file.name).toBe("audio.audio");
74
+ });
75
+
76
+ test.each([
77
+ ["audio/wav", "audio.wav"],
78
+ ["audio/x-wav", "audio.wav"],
79
+ ["audio/mpeg", "audio.mp3"],
80
+ ["audio/mp3", "audio.mp3"],
81
+ ["audio/ogg", "audio.ogg"],
82
+ ["audio/opus", "audio.opus"],
83
+ ["audio/webm", "audio.webm"],
84
+ ["audio/mp4", "audio.m4a"],
85
+ ["audio/x-m4a", "audio.m4a"],
86
+ ["audio/flac", "audio.flac"],
87
+ ])("%s → %s", async (mime, expectedFilename) => {
88
+ await provider.transcribe(dummyAudio, mime);
89
+ const file = capturedFormData?.get("file") as File;
90
+ expect(file.name).toBe(expectedFilename);
91
+ });
92
+ });
93
+ });
@@ -0,0 +1,319 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ import type { OAuthConnection } from "../oauth/connection.js";
4
+
5
+ // ── Mocks ───────────────────────────────────────────────────────────────────
6
+
7
+ const getSecureKeyAsyncMock = mock(
8
+ async (_key: string): Promise<string | null> => null,
9
+ );
10
+ const isProviderConnectedMock = mock(
11
+ async (_service: string): Promise<boolean> => false,
12
+ );
13
+ const resolveOAuthConnectionMock = mock(
14
+ async (
15
+ _service: string,
16
+ _opts?: { account?: string },
17
+ ): Promise<OAuthConnection> =>
18
+ ({ accessToken: "oauth-token" }) as unknown as OAuthConnection,
19
+ );
20
+ const getConnectionByProviderMock = mock(
21
+ (_provider: string): { status: string } | undefined => undefined,
22
+ );
23
+
24
+ mock.module("../security/secure-keys.js", () => ({
25
+ getSecureKeyAsync: getSecureKeyAsyncMock,
26
+ }));
27
+
28
+ mock.module("../oauth/oauth-store.js", () => ({
29
+ isProviderConnected: isProviderConnectedMock,
30
+ getConnectionByProvider: getConnectionByProviderMock,
31
+ }));
32
+
33
+ mock.module("../oauth/connection-resolver.js", () => ({
34
+ resolveOAuthConnection: resolveOAuthConnectionMock,
35
+ }));
36
+
37
+ // Telegram adapter imports modules that need more stubs
38
+ mock.module("../config/env.js", () => ({
39
+ getGatewayInternalBaseUrl: () => "http://localhost:3000",
40
+ }));
41
+ mock.module("../memory/conversation-key-store.js", () => ({
42
+ getOrCreateConversation: async () => "conv-1",
43
+ }));
44
+ mock.module("../memory/external-conversation-store.js", () => ({
45
+ getExternalConversation: () => undefined,
46
+ setExternalConversation: () => {},
47
+ }));
48
+ mock.module("../runtime/auth/token-service.js", () => ({
49
+ mintDaemonDeliveryToken: async () => "delivery-token",
50
+ }));
51
+
52
+ // Slack client stubs (not exercised in these tests, but required on import)
53
+ mock.module("../messaging/providers/slack/client.js", () => ({}));
54
+
55
+ // Gmail client stubs
56
+ mock.module("../messaging/providers/gmail/client.js", () => ({}));
57
+ mock.module("../messaging/providers/gmail/people-client.js", () => ({}));
58
+
59
+ // Telegram client stubs
60
+ mock.module("../messaging/providers/telegram-bot/client.js", () => ({}));
61
+
62
+ import {
63
+ getProviderConnection,
64
+ resolveProvider,
65
+ } from "../config/bundled-skills/messaging/tools/shared.js";
66
+ import { gmailMessagingProvider } from "../messaging/providers/gmail/adapter.js";
67
+ import { slackProvider } from "../messaging/providers/slack/adapter.js";
68
+ import { telegramBotMessagingProvider } from "../messaging/providers/telegram-bot/adapter.js";
69
+ import {
70
+ getConnectedProviders,
71
+ registerMessagingProvider,
72
+ } from "../messaging/registry.js";
73
+
74
+ // Register providers for integration tests
75
+ registerMessagingProvider(slackProvider);
76
+ registerMessagingProvider(gmailMessagingProvider);
77
+ registerMessagingProvider(telegramBotMessagingProvider);
78
+
79
+ // ── Helpers ─────────────────────────────────────────────────────────────────
80
+
81
+ function resetAllMocks() {
82
+ getSecureKeyAsyncMock.mockReset();
83
+ getSecureKeyAsyncMock.mockImplementation(async () => null);
84
+ isProviderConnectedMock.mockReset();
85
+ isProviderConnectedMock.mockImplementation(async () => false);
86
+ resolveOAuthConnectionMock.mockReset();
87
+ resolveOAuthConnectionMock.mockImplementation(
88
+ async () => ({ accessToken: "oauth-token" }) as unknown as OAuthConnection,
89
+ );
90
+ getConnectionByProviderMock.mockReset();
91
+ getConnectionByProviderMock.mockImplementation(() => undefined);
92
+ }
93
+
94
+ // ── Tests ───────────────────────────────────────────────────────────────────
95
+
96
+ describe("Slack messaging token resolution", () => {
97
+ beforeEach(resetAllMocks);
98
+
99
+ // ── slackProvider.isConnected() ─────────────────────────────────────────
100
+
101
+ describe("slackProvider.isConnected()", () => {
102
+ test("returns true when slack_channel bot token exists in credential store", async () => {
103
+ getSecureKeyAsyncMock.mockImplementation(async (key: string) =>
104
+ key === "credential/slack_channel/bot_token" ? "xoxb-bot-token" : null,
105
+ );
106
+
107
+ expect(await slackProvider.isConnected!()).toBe(true);
108
+ });
109
+
110
+ test("returns true even if slack_channel connection row is missing (backfill failure resilience)", async () => {
111
+ // Bot token exists but no connection row — isConnected should still return true
112
+ getSecureKeyAsyncMock.mockImplementation(async (key: string) =>
113
+ key === "credential/slack_channel/bot_token" ? "xoxb-bot-token" : null,
114
+ );
115
+ // No getConnectionByProvider call expected — Slack adapter checks token first
116
+
117
+ expect(await slackProvider.isConnected!()).toBe(true);
118
+ });
119
+
120
+ test("returns true when only integration:slack has active OAuth connection (backwards compat)", async () => {
121
+ // No bot token
122
+ getSecureKeyAsyncMock.mockImplementation(async () => null);
123
+ // But OAuth provider is connected
124
+ isProviderConnectedMock.mockImplementation(async (service: string) =>
125
+ service === "integration:slack" ? true : false,
126
+ );
127
+
128
+ expect(await slackProvider.isConnected!()).toBe(true);
129
+ });
130
+
131
+ test("returns false when neither credential path exists", async () => {
132
+ getSecureKeyAsyncMock.mockImplementation(async () => null);
133
+ isProviderConnectedMock.mockImplementation(async () => false);
134
+
135
+ expect(await slackProvider.isConnected!()).toBe(false);
136
+ });
137
+ });
138
+
139
+ // ── slackProvider.resolveConnection() ───────────────────────────────────
140
+
141
+ describe("slackProvider.resolveConnection()", () => {
142
+ test("returns bot token string when Socket Mode credentials exist", async () => {
143
+ getSecureKeyAsyncMock.mockImplementation(async (key: string) =>
144
+ key === "credential/slack_channel/bot_token"
145
+ ? "xoxb-socket-token"
146
+ : null,
147
+ );
148
+
149
+ const result = await slackProvider.resolveConnection!();
150
+ expect(result).toBe("xoxb-socket-token");
151
+ });
152
+
153
+ test("returns bot token string even without a slack_channel connection row (token-only resilience)", async () => {
154
+ getSecureKeyAsyncMock.mockImplementation(async (key: string) =>
155
+ key === "credential/slack_channel/bot_token" ? "xoxb-token-only" : null,
156
+ );
157
+ // No connection row — resolveConnection should still return the token
158
+
159
+ const result = await slackProvider.resolveConnection!();
160
+ expect(result).toBe("xoxb-token-only");
161
+ });
162
+
163
+ test("returns OAuthConnection when only OAuth integration:slack credentials exist (backwards compat)", async () => {
164
+ getSecureKeyAsyncMock.mockImplementation(async () => null);
165
+ const oauthConn = {
166
+ accessToken: "xoxp-oauth-token",
167
+ } as unknown as OAuthConnection;
168
+ resolveOAuthConnectionMock.mockImplementation(async () => oauthConn);
169
+
170
+ const result = await slackProvider.resolveConnection!();
171
+ expect(result).toBe(oauthConn);
172
+ expect(resolveOAuthConnectionMock).toHaveBeenCalledWith(
173
+ "integration:slack",
174
+ { account: undefined },
175
+ );
176
+ });
177
+
178
+ test("throws when no credentials exist at all (no Socket Mode, no OAuth)", async () => {
179
+ getSecureKeyAsyncMock.mockImplementation(async () => null);
180
+ resolveOAuthConnectionMock.mockImplementation(async () => {
181
+ throw new Error("No OAuth connection found for integration:slack");
182
+ });
183
+
184
+ await expect(slackProvider.resolveConnection!()).rejects.toThrow(
185
+ "No OAuth connection found",
186
+ );
187
+ });
188
+ });
189
+
190
+ // ── getProviderConnection() integration ─────────────────────────────────
191
+
192
+ describe("getProviderConnection()", () => {
193
+ test("returns bot token string for Slack when Socket Mode credentials exist", async () => {
194
+ getSecureKeyAsyncMock.mockImplementation(async (key: string) =>
195
+ key === "credential/slack_channel/bot_token" ? "xoxb-conn-token" : null,
196
+ );
197
+
198
+ const result = await getProviderConnection(slackProvider);
199
+ expect(result).toBe("xoxb-conn-token");
200
+ });
201
+
202
+ test("returns OAuthConnection for Slack when only OAuth credentials exist (backwards compat)", async () => {
203
+ getSecureKeyAsyncMock.mockImplementation(async () => null);
204
+ const oauthConn = {
205
+ accessToken: "xoxp-oauth-token",
206
+ } as unknown as OAuthConnection;
207
+ resolveOAuthConnectionMock.mockImplementation(async () => oauthConn);
208
+
209
+ const result = await getProviderConnection(slackProvider);
210
+ expect(result).toBe(oauthConn);
211
+ });
212
+
213
+ test("throws when no Slack credentials exist", async () => {
214
+ getSecureKeyAsyncMock.mockImplementation(async () => null);
215
+ resolveOAuthConnectionMock.mockImplementation(async () => {
216
+ throw new Error("No OAuth connection found");
217
+ });
218
+
219
+ await expect(getProviderConnection(slackProvider)).rejects.toThrow(
220
+ "No OAuth connection found",
221
+ );
222
+ });
223
+
224
+ test('Telegram still returns "" (no resolveConnection, uses isConnected path — regression check)', async () => {
225
+ // Telegram has isConnected but no resolveConnection.
226
+ // When isConnected returns true, getProviderConnection returns ""
227
+ getSecureKeyAsyncMock.mockImplementation(async (key: string) => {
228
+ if (key === "credential/telegram/bot_token") return "bot-token";
229
+ if (key === "credential/telegram/webhook_secret") return "secret";
230
+ return null;
231
+ });
232
+ getConnectionByProviderMock.mockImplementation((provider: string) =>
233
+ provider === "telegram" ? { status: "active" } : undefined,
234
+ );
235
+
236
+ const result = await getProviderConnection(telegramBotMessagingProvider);
237
+ expect(result).toBe("");
238
+ });
239
+
240
+ test("Gmail still calls resolveOAuthConnection (no resolveConnection, no isConnected — regression check)", async () => {
241
+ // Gmail has neither resolveConnection nor isConnected.
242
+ // getProviderConnection falls through to resolveOAuthConnection.
243
+ const oauthConn = {
244
+ accessToken: "gmail-oauth-token",
245
+ } as unknown as OAuthConnection;
246
+ resolveOAuthConnectionMock.mockImplementation(async () => oauthConn);
247
+
248
+ const result = await getProviderConnection(gmailMessagingProvider);
249
+ expect(result).toBe(oauthConn);
250
+ expect(resolveOAuthConnectionMock).toHaveBeenCalledWith(
251
+ "integration:google",
252
+ { account: undefined },
253
+ );
254
+ });
255
+ });
256
+
257
+ // ── resolveProvider() multi-platform behavior ───────────────────────────
258
+
259
+ describe("resolveProvider() multi-platform behavior", () => {
260
+ test('throws "Multiple platforms connected" when both Gmail and Slack (Socket Mode) are connected and no platform is specified', async () => {
261
+ // Slack connected via Socket Mode
262
+ getSecureKeyAsyncMock.mockImplementation(async (key: string) =>
263
+ key === "credential/slack_channel/bot_token" ? "xoxb-token" : null,
264
+ );
265
+ // Gmail connected via OAuth
266
+ isProviderConnectedMock.mockImplementation(async (service: string) =>
267
+ service === "integration:google" ? true : false,
268
+ );
269
+
270
+ await expect(resolveProvider()).rejects.toThrow(
271
+ "Multiple platforms connected",
272
+ );
273
+ });
274
+
275
+ test("auto-selects Slack when it is the only connected provider", async () => {
276
+ getSecureKeyAsyncMock.mockImplementation(async (key: string) =>
277
+ key === "credential/slack_channel/bot_token" ? "xoxb-only" : null,
278
+ );
279
+ isProviderConnectedMock.mockImplementation(async () => false);
280
+
281
+ const provider = await resolveProvider();
282
+ expect(provider.id).toBe("slack");
283
+ });
284
+
285
+ test("auto-selects Gmail when it is the only connected provider (no Slack credentials)", async () => {
286
+ getSecureKeyAsyncMock.mockImplementation(async () => null);
287
+ isProviderConnectedMock.mockImplementation(async (service: string) =>
288
+ service === "integration:google" ? true : false,
289
+ );
290
+
291
+ const provider = await resolveProvider();
292
+ expect(provider.id).toBe("gmail");
293
+ });
294
+ });
295
+
296
+ // ── getConnectedProviders() ─────────────────────────────────────────────
297
+
298
+ describe("getConnectedProviders()", () => {
299
+ test("includes Slack when connected via Socket Mode (slack_channel)", async () => {
300
+ getSecureKeyAsyncMock.mockImplementation(async (key: string) =>
301
+ key === "credential/slack_channel/bot_token" ? "xoxb-token" : null,
302
+ );
303
+ isProviderConnectedMock.mockImplementation(async () => false);
304
+
305
+ const connected = await getConnectedProviders();
306
+ const ids = connected.map((p) => p.id);
307
+ expect(ids).toContain("slack");
308
+ });
309
+
310
+ test("excludes Slack when no bot token exists", async () => {
311
+ getSecureKeyAsyncMock.mockImplementation(async () => null);
312
+ isProviderConnectedMock.mockImplementation(async () => false);
313
+
314
+ const connected = await getConnectedProviders();
315
+ const ids = connected.map((p) => p.id);
316
+ expect(ids).not.toContain("slack");
317
+ });
318
+ });
319
+ });