@vellumai/assistant 0.5.3 → 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/docs/architecture/memory.md +105 -0
- 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__/archive-recall.test.ts +560 -0
- package/src/__tests__/conversation-clear-safety.test.ts +259 -0
- package/src/__tests__/conversation-switch-memory-reduction.test.ts +474 -0
- package/src/__tests__/credential-security-invariants.test.ts +2 -0
- package/src/__tests__/db-schedule-syntax-migration.test.ts +3 -0
- package/src/__tests__/memory-reducer-job.test.ts +538 -0
- package/src/__tests__/memory-reducer-scheduling.test.ts +473 -0
- package/src/__tests__/memory-reducer-types.test.ts +12 -4
- package/src/__tests__/memory-reducer.test.ts +7 -1
- package/src/__tests__/memory-regressions.test.ts +24 -4
- package/src/__tests__/memory-simplified-config.test.ts +4 -4
- package/src/__tests__/openai-whisper.test.ts +93 -0
- package/src/__tests__/simplified-memory-e2e.test.ts +666 -0
- package/src/__tests__/simplified-memory-runtime.test.ts +616 -0
- package/src/__tests__/slack-messaging-token-resolution.test.ts +319 -0
- package/src/__tests__/volume-security-guard.test.ts +155 -0
- package/src/cli/commands/conversations.ts +18 -0
- package/src/config/bundled-skills/messaging/tools/shared.ts +1 -0
- package/src/config/bundled-skills/schedule/TOOLS.json +8 -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/config/loader.ts +0 -1
- package/src/config/schemas/memory-simplified.ts +1 -1
- package/src/credential-execution/managed-catalog.ts +5 -15
- package/src/daemon/config-watcher.ts +4 -1
- package/src/daemon/conversation-memory.ts +117 -0
- package/src/daemon/conversation-runtime-assembly.ts +1 -0
- package/src/daemon/daemon-control.ts +7 -0
- package/src/daemon/handlers/conversations.ts +11 -0
- package/src/daemon/lifecycle.ts +51 -2
- package/src/daemon/providers-setup.ts +2 -1
- package/src/hooks/manager.ts +7 -0
- package/src/instrument.ts +33 -1
- package/src/memory/archive-recall.ts +516 -0
- package/src/memory/brief-time.ts +5 -4
- package/src/memory/conversation-crud.ts +210 -0
- package/src/memory/conversation-key-store.ts +33 -4
- package/src/memory/db-init.ts +4 -0
- package/src/memory/embedding-local.ts +11 -5
- package/src/memory/job-handlers/backfill-simplified-memory.ts +462 -0
- package/src/memory/job-handlers/conversation-starters.ts +24 -30
- package/src/memory/job-handlers/reduce-conversation-memory.ts +229 -0
- package/src/memory/jobs-store.ts +2 -0
- package/src/memory/jobs-worker.ts +8 -0
- package/src/memory/migrations/036-normalize-phone-identities.ts +49 -14
- package/src/memory/migrations/135-backfill-contact-interaction-stats.ts +9 -1
- package/src/memory/migrations/141-rename-verification-table.ts +8 -0
- package/src/memory/migrations/142-rename-verification-session-id-column.ts +7 -2
- package/src/memory/migrations/174-rename-thread-starters-table.ts +8 -0
- package/src/memory/migrations/188-schedule-quiet-flag.ts +13 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/reducer-scheduler.ts +242 -0
- package/src/memory/reducer-types.ts +9 -2
- package/src/memory/reducer.ts +25 -11
- package/src/memory/schema/infrastructure.ts +1 -0
- 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/auth/route-policy.ts +10 -1
- package/src/runtime/http-server.ts +2 -2
- package/src/runtime/routes/conversation-management-routes.ts +88 -2
- package/src/runtime/routes/guardian-bootstrap-routes.ts +19 -7
- 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 +5 -1
- package/src/schedule/schedule-store.ts +7 -0
- package/src/schedule/scheduler.ts +6 -2
- 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 +22 -20
- package/src/tools/filesystem/edit.ts +6 -1
- package/src/tools/filesystem/read.ts +6 -1
- package/src/tools/filesystem/write.ts +6 -1
- package/src/tools/memory/handlers.ts +129 -1
- package/src/tools/schedule/create.ts +3 -0
- package/src/tools/schedule/list.ts +5 -1
- package/src/tools/schedule/update.ts +6 -0
- 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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Guard test: assistant source code must not directly access files in the
|
|
7
|
+
* `protected/` directory (`trust.json`, `keys.enc`, `store.key`,
|
|
8
|
+
* `actor-token-signing-key`). In containerized (Docker) mode these files
|
|
9
|
+
* live outside the assistant's data volume and are managed by the gateway.
|
|
10
|
+
*
|
|
11
|
+
* All access must go through the appropriate abstraction layer:
|
|
12
|
+
* - Trust rules: trust-store.ts / trust-client.ts (file vs gateway backend)
|
|
13
|
+
* - Credentials: encrypted-store.ts / ces-credential-client.ts
|
|
14
|
+
* - Signing keys: secure-keys.ts / credential-backend.ts
|
|
15
|
+
*
|
|
16
|
+
* Only the abstraction-layer files themselves (and tests) are allowed to
|
|
17
|
+
* reference the raw file paths / helper functions.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Allowed files — abstraction layers that legitimately access protected/ files
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
const ALLOWED_FILES = new Set([
|
|
25
|
+
// Trust store backends
|
|
26
|
+
"assistant/src/permissions/trust-store.ts",
|
|
27
|
+
"assistant/src/permissions/trust-client.ts",
|
|
28
|
+
"assistant/src/permissions/trust-store-interface.ts",
|
|
29
|
+
// Credential / encrypted store backends
|
|
30
|
+
"assistant/src/security/encrypted-store.ts",
|
|
31
|
+
"assistant/src/security/secure-keys.ts",
|
|
32
|
+
"assistant/src/security/credential-backend.ts",
|
|
33
|
+
"assistant/src/security/ces-credential-client.ts",
|
|
34
|
+
// Token service owns the signing key lifecycle
|
|
35
|
+
"assistant/src/runtime/auth/token-service.ts",
|
|
36
|
+
// CLI commands that run outside Docker (doctor diagnostics, trust management)
|
|
37
|
+
"assistant/src/cli/commands/doctor.ts",
|
|
38
|
+
"assistant/src/cli/commands/trust.ts",
|
|
39
|
+
// Auth middleware documentation comment (not a file access)
|
|
40
|
+
"assistant/src/runtime/auth/middleware.ts",
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Patterns that indicate direct access to protected directory files
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Each entry is a `git grep -E` pattern and a human-readable description
|
|
49
|
+
* for the error message.
|
|
50
|
+
*/
|
|
51
|
+
const GUARDED_PATTERNS: Array<{ pattern: string; description: string }> = [
|
|
52
|
+
{
|
|
53
|
+
pattern: "protected/trust\\.json",
|
|
54
|
+
description: "direct reference to protected/trust.json",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
pattern: "protected/keys\\.enc",
|
|
58
|
+
description: "direct reference to protected/keys.enc",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
pattern: "protected/store\\.key",
|
|
62
|
+
description: "direct reference to protected/store.key",
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
pattern: "actor-token-signing-key",
|
|
66
|
+
description: "direct reference to actor-token-signing-key file",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
pattern: "\\bgetTrustPath\\b",
|
|
70
|
+
description: "use of getTrustPath() (trust-store internal)",
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
pattern: "\\bgetStoreKeyPath\\b",
|
|
74
|
+
description: "use of getStoreKeyPath() (encrypted-store internal)",
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Helpers
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
function getRepoRoot(): string {
|
|
83
|
+
return join(process.cwd(), "..");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isTestFile(filePath: string): boolean {
|
|
87
|
+
return (
|
|
88
|
+
filePath.includes("/__tests__/") ||
|
|
89
|
+
filePath.endsWith(".test.ts") ||
|
|
90
|
+
filePath.endsWith(".test.js") ||
|
|
91
|
+
filePath.endsWith(".spec.ts") ||
|
|
92
|
+
filePath.endsWith(".spec.js")
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Tests
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
describe("volume security: protected directory access guard", () => {
|
|
101
|
+
for (const { pattern, description } of GUARDED_PATTERNS) {
|
|
102
|
+
test(`no ${description} outside allowed files`, () => {
|
|
103
|
+
const repoRoot = getRepoRoot();
|
|
104
|
+
|
|
105
|
+
let grepOutput = "";
|
|
106
|
+
try {
|
|
107
|
+
grepOutput = execFileSync(
|
|
108
|
+
"git",
|
|
109
|
+
[
|
|
110
|
+
"grep",
|
|
111
|
+
"-lE",
|
|
112
|
+
pattern,
|
|
113
|
+
"--",
|
|
114
|
+
"assistant/src/**/*.ts",
|
|
115
|
+
"assistant/src/*.ts",
|
|
116
|
+
],
|
|
117
|
+
{ encoding: "utf-8", cwd: repoRoot },
|
|
118
|
+
).trim();
|
|
119
|
+
} catch (err) {
|
|
120
|
+
// Exit code 1 means no matches — happy path
|
|
121
|
+
if ((err as { status?: number }).status === 1) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
throw err;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const files = grepOutput.split("\n").filter((f) => f.length > 0);
|
|
128
|
+
const violations = files.filter(
|
|
129
|
+
(f) => !isTestFile(f) && !ALLOWED_FILES.has(f),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
if (violations.length > 0) {
|
|
133
|
+
const message = [
|
|
134
|
+
`Found assistant source files with ${description}.`,
|
|
135
|
+
"",
|
|
136
|
+
"In containerized (Docker) mode, the protected/ directory is not",
|
|
137
|
+
"accessible to the assistant. All access to protected files must go",
|
|
138
|
+
"through the abstraction layers:",
|
|
139
|
+
" - Trust rules: trust-store.ts / trust-client.ts",
|
|
140
|
+
" - Credentials: encrypted-store.ts / ces-credential-client.ts",
|
|
141
|
+
" - Signing keys: secure-keys.ts / credential-backend.ts",
|
|
142
|
+
"",
|
|
143
|
+
"If this file is a new abstraction backend, add it to ALLOWED_FILES",
|
|
144
|
+
"in this guard test. Otherwise, use the appropriate abstraction layer",
|
|
145
|
+
"or gate the access behind !getIsContainerized().",
|
|
146
|
+
"",
|
|
147
|
+
"Violations:",
|
|
148
|
+
...violations.map((f) => ` - ${f}`),
|
|
149
|
+
].join("\n");
|
|
150
|
+
|
|
151
|
+
expect(violations, message).toEqual([]);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
});
|
|
@@ -375,6 +375,24 @@ Examples:
|
|
|
375
375
|
targetId: summaryId,
|
|
376
376
|
});
|
|
377
377
|
}
|
|
378
|
+
for (const obsId of result.deletedObservationIds) {
|
|
379
|
+
enqueueMemoryJob("delete_qdrant_vectors", {
|
|
380
|
+
targetType: "observation",
|
|
381
|
+
targetId: obsId,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
for (const chunkId of result.deletedChunkIds) {
|
|
385
|
+
enqueueMemoryJob("delete_qdrant_vectors", {
|
|
386
|
+
targetType: "chunk",
|
|
387
|
+
targetId: chunkId,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
for (const episodeId of result.deletedEpisodeIds) {
|
|
391
|
+
enqueueMemoryJob("delete_qdrant_vectors", {
|
|
392
|
+
targetType: "episode",
|
|
393
|
+
targetId: episodeId,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
378
396
|
|
|
379
397
|
log.info(
|
|
380
398
|
`Wiped conversation "${conversation.title ?? "Untitled"}". ` +
|
|
@@ -136,6 +136,7 @@ export async function getProviderConnection(
|
|
|
136
136
|
provider: MessagingProvider,
|
|
137
137
|
account?: string,
|
|
138
138
|
): Promise<OAuthConnection | string> {
|
|
139
|
+
if (provider.resolveConnection) return provider.resolveConnection(account);
|
|
139
140
|
if (await provider.isConnected?.()) return "";
|
|
140
141
|
return resolveOAuthConnection(provider.credentialService, { account });
|
|
141
142
|
}
|
|
@@ -52,6 +52,10 @@
|
|
|
52
52
|
"type": "object",
|
|
53
53
|
"description": "Additional routing metadata (e.g. preferred channel identifiers)"
|
|
54
54
|
},
|
|
55
|
+
"quiet": {
|
|
56
|
+
"type": "boolean",
|
|
57
|
+
"description": "When true, suppress completion notifications for this schedule. The job still runs and produces output, but no notification or conversation message is sent on completion. Useful for high-frequency recurring jobs that report findings separately. Defaults to false."
|
|
58
|
+
},
|
|
55
59
|
"activity": {
|
|
56
60
|
"type": "string",
|
|
57
61
|
"description": "Brief non-technical explanation of why this tool is being called"
|
|
@@ -139,6 +143,10 @@
|
|
|
139
143
|
"type": "object",
|
|
140
144
|
"description": "Additional routing metadata (e.g. preferred channel identifiers)"
|
|
141
145
|
},
|
|
146
|
+
"quiet": {
|
|
147
|
+
"type": "boolean",
|
|
148
|
+
"description": "When true, suppress completion notifications for this schedule. Useful for high-frequency jobs that report findings separately."
|
|
149
|
+
},
|
|
142
150
|
"activity": {
|
|
143
151
|
"type": "string",
|
|
144
152
|
"description": "Brief non-technical explanation of why this tool is being called"
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import { tmpdir } from "node:os";
|
|
11
11
|
import { extname, join } from "node:path";
|
|
12
12
|
|
|
13
|
+
import { OpenAIWhisperProvider } from "../../../../providers/speech-to-text/openai-whisper.js";
|
|
13
14
|
import { getProviderKeyAsync } from "../../../../security/secure-keys.js";
|
|
14
15
|
import type {
|
|
15
16
|
ToolContext,
|
|
@@ -168,12 +169,19 @@ async function transcribeViaApi(
|
|
|
168
169
|
apiKey: string,
|
|
169
170
|
context: ToolContext,
|
|
170
171
|
): Promise<string> {
|
|
172
|
+
const provider = new OpenAIWhisperProvider(apiKey);
|
|
171
173
|
const duration = await getAudioDuration(audioPath);
|
|
172
174
|
const fileSize = Bun.file(audioPath).size;
|
|
173
175
|
|
|
174
176
|
// If small enough, send directly
|
|
175
177
|
if (fileSize <= WHISPER_API_MAX_BYTES) {
|
|
176
|
-
|
|
178
|
+
const audioBuffer = await readFile(audioPath);
|
|
179
|
+
const result = await provider.transcribe(
|
|
180
|
+
audioBuffer,
|
|
181
|
+
"audio/wav",
|
|
182
|
+
AbortSignal.timeout(API_REQUEST_TIMEOUT_MS),
|
|
183
|
+
);
|
|
184
|
+
return result.text;
|
|
177
185
|
}
|
|
178
186
|
|
|
179
187
|
// Split into chunks for large files
|
|
@@ -199,8 +207,13 @@ async function transcribeViaApi(
|
|
|
199
207
|
for (let i = 0; i < chunks.length; i++) {
|
|
200
208
|
if (context.signal?.aborted) throw new Error("Cancelled");
|
|
201
209
|
context.onOutput?.(` Transcribing chunk ${i + 1}/${chunks.length}...\n`);
|
|
202
|
-
const
|
|
203
|
-
|
|
210
|
+
const audioBuffer = await readFile(chunks[i]);
|
|
211
|
+
const result = await provider.transcribe(
|
|
212
|
+
audioBuffer,
|
|
213
|
+
"audio/wav",
|
|
214
|
+
AbortSignal.timeout(API_REQUEST_TIMEOUT_MS),
|
|
215
|
+
);
|
|
216
|
+
if (result.text) parts.push(result.text);
|
|
204
217
|
}
|
|
205
218
|
|
|
206
219
|
return parts.join(" ");
|
|
@@ -213,40 +226,6 @@ async function transcribeViaApi(
|
|
|
213
226
|
}
|
|
214
227
|
}
|
|
215
228
|
|
|
216
|
-
async function whisperApiRequest(
|
|
217
|
-
audioPath: string,
|
|
218
|
-
apiKey: string,
|
|
219
|
-
): Promise<string> {
|
|
220
|
-
const audioData = await readFile(audioPath);
|
|
221
|
-
const formData = new FormData();
|
|
222
|
-
formData.append(
|
|
223
|
-
"file",
|
|
224
|
-
new Blob([audioData], { type: "audio/wav" }),
|
|
225
|
-
"audio.wav",
|
|
226
|
-
);
|
|
227
|
-
formData.append("model", "whisper-1");
|
|
228
|
-
|
|
229
|
-
const response = await fetch(
|
|
230
|
-
"https://api.openai.com/v1/audio/transcriptions",
|
|
231
|
-
{
|
|
232
|
-
method: "POST",
|
|
233
|
-
headers: { Authorization: `Bearer ${apiKey}` },
|
|
234
|
-
body: formData,
|
|
235
|
-
signal: AbortSignal.timeout(API_REQUEST_TIMEOUT_MS),
|
|
236
|
-
},
|
|
237
|
-
);
|
|
238
|
-
|
|
239
|
-
if (!response.ok) {
|
|
240
|
-
const body = await response.text().catch(() => "");
|
|
241
|
-
throw new Error(
|
|
242
|
-
`Whisper API error (${response.status}): ${body.slice(0, 300)}`,
|
|
243
|
-
);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
const result = (await response.json()) as { text?: string };
|
|
247
|
-
return result.text?.trim() ?? "";
|
|
248
|
-
}
|
|
249
|
-
|
|
250
229
|
// ---------------------------------------------------------------------------
|
|
251
230
|
// Local mode - whisper.cpp
|
|
252
231
|
// ---------------------------------------------------------------------------
|
|
@@ -54,6 +54,15 @@ export function getIsContainerized(): boolean {
|
|
|
54
54
|
return flag("IS_CONTAINERIZED");
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* WORKSPACE_DIR — string, default: undefined
|
|
59
|
+
* When set, overrides the default workspace directory. Used in containerized
|
|
60
|
+
* deployments where the workspace is a separate volume.
|
|
61
|
+
*/
|
|
62
|
+
export function getWorkspaceDirOverride(): string | undefined {
|
|
63
|
+
return str("WORKSPACE_DIR");
|
|
64
|
+
}
|
|
65
|
+
|
|
57
66
|
// ── Known env var names ──────────────────────────────────────────────────────
|
|
58
67
|
|
|
59
68
|
/**
|
|
@@ -288,6 +288,14 @@
|
|
|
288
288
|
"label": "Inline Skill Command Expansion",
|
|
289
289
|
"description": "Enable secure inline skill command expansion via !`command` syntax, with version-pinned approval and sandboxed execution at skill load time",
|
|
290
290
|
"defaultEnabled": true
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
"id": "channel-voice-transcription",
|
|
294
|
+
"scope": "assistant",
|
|
295
|
+
"key": "feature_flags.channel-voice-transcription.enabled",
|
|
296
|
+
"label": "Channel Voice Transcription",
|
|
297
|
+
"description": "Auto-transcribe voice/audio messages received from channels (Telegram, WhatsApp) before processing",
|
|
298
|
+
"defaultEnabled": true
|
|
291
299
|
}
|
|
292
300
|
]
|
|
293
301
|
}
|
package/src/config/loader.ts
CHANGED
|
@@ -71,7 +71,7 @@ export const MemorySimplifiedConfigSchema = z
|
|
|
71
71
|
.boolean({
|
|
72
72
|
error: "memory.simplified.enabled must be a boolean",
|
|
73
73
|
})
|
|
74
|
-
.default(
|
|
74
|
+
.default(true)
|
|
75
75
|
.describe("Whether the simplified memory system is enabled"),
|
|
76
76
|
brief: MemorySimplifiedBriefConfigSchema.default(
|
|
77
77
|
MemorySimplifiedBriefConfigSchema.parse({}),
|