@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.
- package/Dockerfile +18 -27
- package/node_modules/@vellumai/ces-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/ces-contracts/src/trust-rules.ts +42 -0
- package/package.json +1 -1
- package/src/__tests__/credential-security-invariants.test.ts +2 -0
- package/src/__tests__/openai-whisper.test.ts +93 -0
- package/src/__tests__/slack-messaging-token-resolution.test.ts +319 -0
- package/src/__tests__/volume-security-guard.test.ts +155 -0
- package/src/config/bundled-skills/messaging/tools/shared.ts +1 -0
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +16 -37
- package/src/config/env-registry.ts +9 -0
- package/src/config/feature-flag-registry.json +8 -0
- package/src/credential-execution/managed-catalog.ts +5 -15
- package/src/daemon/config-watcher.ts +4 -1
- package/src/daemon/daemon-control.ts +7 -0
- package/src/daemon/lifecycle.ts +7 -1
- package/src/daemon/providers-setup.ts +2 -1
- package/src/hooks/manager.ts +7 -0
- package/src/instrument.ts +33 -1
- package/src/memory/embedding-local.ts +11 -5
- package/src/memory/job-handlers/conversation-starters.ts +24 -36
- package/src/messaging/provider.ts +9 -0
- package/src/messaging/providers/slack/adapter.ts +29 -2
- package/src/oauth/connection-resolver.test.ts +22 -18
- package/src/oauth/connection-resolver.ts +92 -7
- package/src/oauth/platform-connection.test.ts +78 -69
- package/src/oauth/platform-connection.ts +12 -19
- package/src/permissions/trust-client.ts +343 -0
- package/src/permissions/trust-store-interface.ts +105 -0
- package/src/permissions/trust-store.ts +523 -36
- package/src/platform/client.test.ts +148 -0
- package/src/platform/client.ts +71 -0
- package/src/providers/speech-to-text/openai-whisper.test.ts +190 -0
- package/src/providers/speech-to-text/openai-whisper.ts +68 -0
- package/src/providers/speech-to-text/resolve.ts +9 -0
- package/src/providers/speech-to-text/types.ts +17 -0
- package/src/runtime/http-server.ts +2 -2
- package/src/runtime/routes/inbound-message-handler.ts +27 -3
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +16 -1
- package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +287 -0
- package/src/runtime/routes/inbound-stages/transcribe-audio.ts +122 -0
- package/src/runtime/routes/log-export-routes.ts +1 -0
- package/src/runtime/routes/secret-routes.ts +4 -1
- package/src/security/ces-credential-client.ts +173 -0
- package/src/security/secure-keys.ts +65 -22
- package/src/signals/bash.ts +3 -0
- package/src/signals/cancel.ts +3 -0
- package/src/signals/confirm.ts +3 -0
- package/src/signals/conversation-undo.ts +3 -0
- package/src/signals/event-stream.ts +7 -0
- package/src/signals/shotgun.ts +3 -0
- package/src/signals/trust-rule.ts +3 -0
- package/src/telemetry/usage-telemetry-reporter.test.ts +23 -36
- package/src/telemetry/usage-telemetry-reporter.ts +21 -19
- package/src/util/device-id.ts +70 -7
- package/src/util/logger.ts +35 -9
- package/src/util/platform.ts +29 -5
- package/src/workspace/migrations/migrate-to-workspace-volume.ts +113 -0
- 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
|
|
62
|
-
RUN mkdir -p /home/assistant/.vellum
|
|
63
|
-
chown -R assistant:assistant /home/assistant/.vellum
|
|
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
|
|
76
|
+
ENV PATH="/home/assistant/.bun/bin:${PATH}"
|
|
68
77
|
|
|
69
|
-
# Configure package managers to use
|
|
70
|
-
ENV BUN_INSTALL="/
|
|
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="/
|
|
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
|
|
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
|
|
@@ -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
|
@@ -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
|
+
});
|