@vellumai/assistant 0.4.37 → 0.4.41

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 (169) hide show
  1. package/ARCHITECTURE.md +3 -3
  2. package/README.md +13 -13
  3. package/bun.lock +80 -24
  4. package/docs/architecture/integrations.md +126 -128
  5. package/docs/runbook-trusted-contacts.md +1 -1
  6. package/docs/trusted-contact-access.md +12 -12
  7. package/package.json +3 -1
  8. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +0 -14
  9. package/src/__tests__/app-bundler.test.ts +209 -0
  10. package/src/__tests__/app-compiler.test.ts +279 -0
  11. package/src/__tests__/app-executors.test.ts +293 -483
  12. package/src/__tests__/app-migration.test.ts +148 -0
  13. package/src/__tests__/app-routes-csp.test.ts +202 -0
  14. package/src/__tests__/avatar-e2e.test.ts +452 -0
  15. package/src/__tests__/avatar-generator.test.ts +193 -0
  16. package/src/__tests__/avatar-router.test.ts +186 -0
  17. package/src/__tests__/browser-download-timeout.test.ts +28 -0
  18. package/src/__tests__/bundled-skill-retrieval-guard.test.ts +9 -9
  19. package/src/__tests__/call-domain.test.ts +3 -7
  20. package/src/__tests__/credential-security-e2e.test.ts +19 -12
  21. package/src/__tests__/credentials-cli.test.ts +30 -4
  22. package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +1 -1
  23. package/src/__tests__/handlers-slack-config.test.ts +0 -72
  24. package/src/__tests__/handlers-telegram-config.test.ts +19 -12
  25. package/src/__tests__/handlers-twitter-config.test.ts +105 -48
  26. package/src/__tests__/inbound-invite-redemption.test.ts +4 -4
  27. package/src/__tests__/integration-status.test.ts +15 -5
  28. package/src/__tests__/integrations-cli.test.ts +1 -1
  29. package/src/__tests__/invite-redemption-service.test.ts +62 -7
  30. package/src/__tests__/ipc-snapshot.test.ts +0 -8
  31. package/src/__tests__/managed-avatar-client.test.ts +280 -0
  32. package/src/__tests__/mcp-cli.test.ts +3 -3
  33. package/src/__tests__/oauth-cli.test.ts +203 -0
  34. package/src/__tests__/relay-server.test.ts +3 -3
  35. package/src/__tests__/secret-onetime-send.test.ts +19 -12
  36. package/src/__tests__/secure-keys.test.ts +78 -0
  37. package/src/__tests__/session-messaging-secret-redirect.test.ts +3 -0
  38. package/src/__tests__/slack-channel-config.test.ts +23 -16
  39. package/src/__tests__/slack-share-routes.test.ts +263 -0
  40. package/src/__tests__/sms-messaging-provider.test.ts +3 -1
  41. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +7 -7
  42. package/src/__tests__/trusted-contact-multichannel.test.ts +3 -3
  43. package/src/__tests__/trusted-contact-verification.test.ts +10 -10
  44. package/src/__tests__/twilio-config.test.ts +15 -36
  45. package/src/__tests__/twilio-provider.test.ts +4 -0
  46. package/src/__tests__/twitter-auth-handler.test.ts +27 -14
  47. package/src/__tests__/twitter-cli-error-shaping.test.ts +1 -1
  48. package/src/__tests__/twitter-cli-routing.test.ts +38 -53
  49. package/src/__tests__/twitter-oauth-client.test.ts +18 -47
  50. package/src/__tests__/voice-invite-redemption.test.ts +27 -3
  51. package/src/amazon/cart.ts +1 -1
  52. package/src/amazon/client.ts +89 -7
  53. package/src/approvals/guardian-request-resolvers.ts +2 -2
  54. package/src/bundler/app-bundler.ts +77 -32
  55. package/src/bundler/app-compiler.ts +195 -0
  56. package/src/bundler/manifest.ts +1 -1
  57. package/src/bundler/package-resolver.ts +185 -0
  58. package/src/calls/call-domain.ts +4 -14
  59. package/src/calls/relay-server.ts +2 -2
  60. package/src/calls/twilio-config.ts +5 -24
  61. package/src/calls/twilio-rest.ts +19 -5
  62. package/src/cli/amazon.ts +74 -249
  63. package/src/cli/audit.ts +2 -2
  64. package/src/cli/autonomy.ts +9 -9
  65. package/src/cli/channels.ts +5 -5
  66. package/src/cli/completions.ts +27 -27
  67. package/src/cli/config.ts +14 -14
  68. package/src/cli/contacts.ts +27 -27
  69. package/src/cli/credentials.ts +28 -28
  70. package/src/cli/dev.ts +2 -2
  71. package/src/cli/doctor.ts +2 -2
  72. package/src/cli/email.ts +82 -82
  73. package/src/cli/influencer.ts +13 -13
  74. package/src/cli/integrations.ts +19 -144
  75. package/src/cli/keys.ts +10 -10
  76. package/src/cli/map.ts +4 -4
  77. package/src/cli/mcp.ts +17 -17
  78. package/src/cli/memory.ts +18 -18
  79. package/src/cli/notifications.ts +13 -13
  80. package/src/cli/oauth.ts +77 -0
  81. package/src/cli/program.ts +2 -0
  82. package/src/cli/sequence.ts +27 -27
  83. package/src/cli/sessions.ts +12 -12
  84. package/src/cli/trust.ts +8 -8
  85. package/src/cli/twitter.ts +124 -70
  86. package/src/config/bundled-skills/_shared/CLI_RETRIEVAL_PATTERN.md +1 -1
  87. package/src/config/bundled-skills/agentmail/SKILL.md +34 -34
  88. package/src/config/bundled-skills/amazon/SKILL.md +54 -54
  89. package/src/config/bundled-skills/app-builder/SKILL.md +137 -3
  90. package/src/config/bundled-skills/app-builder/tools/app-create.ts +10 -4
  91. package/src/config/bundled-skills/configure-settings/SKILL.md +18 -18
  92. package/src/config/bundled-skills/contacts/SKILL.md +12 -12
  93. package/src/config/bundled-skills/doordash/lib/client.ts +7 -9
  94. package/src/config/bundled-skills/email-setup/SKILL.md +4 -4
  95. package/src/config/bundled-skills/frontend-design/icon.svg +16 -0
  96. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +143 -162
  97. package/src/config/bundled-skills/guardian-verify-setup/SKILL.md +4 -4
  98. package/src/config/bundled-skills/influencer/SKILL.md +13 -13
  99. package/src/config/bundled-skills/mcp-setup/SKILL.md +11 -11
  100. package/src/config/bundled-skills/phone-calls/SKILL.md +48 -54
  101. package/src/config/bundled-skills/public-ingress/SKILL.md +6 -6
  102. package/src/config/bundled-skills/slack-app-setup/SKILL.md +1 -1
  103. package/src/config/bundled-skills/sms-setup/SKILL.md +3 -3
  104. package/src/config/bundled-skills/telegram-setup/SKILL.md +2 -2
  105. package/src/config/bundled-skills/twilio-setup/SKILL.md +136 -225
  106. package/src/config/bundled-skills/twitter/SKILL.md +68 -44
  107. package/src/config/bundled-skills/voice-setup/SKILL.md +2 -2
  108. package/src/config/core-schema.ts +26 -0
  109. package/src/config/env.ts +4 -0
  110. package/src/config/feature-flag-registry.json +9 -1
  111. package/src/config/schema.ts +8 -0
  112. package/src/config/system-prompt.ts +6 -3
  113. package/src/config/templates/BOOTSTRAP.md +7 -5
  114. package/src/contacts/contacts-write.ts +5 -1
  115. package/src/daemon/handlers/apps.ts +31 -4
  116. package/src/daemon/handlers/config-ingress.ts +3 -3
  117. package/src/daemon/handlers/config-integrations.ts +120 -49
  118. package/src/daemon/handlers/config-slack-channel.ts +26 -7
  119. package/src/daemon/handlers/config-slack.ts +1 -54
  120. package/src/daemon/handlers/config-telegram.ts +28 -10
  121. package/src/daemon/handlers/config.ts +1 -4
  122. package/src/daemon/handlers/twitter-auth.ts +11 -4
  123. package/src/daemon/ipc-contract/apps.ts +0 -13
  124. package/src/daemon/ipc-contract-inventory.json +0 -2
  125. package/src/daemon/lifecycle.ts +8 -1
  126. package/src/daemon/session-messaging.ts +2 -2
  127. package/src/daemon/tool-side-effects.ts +30 -0
  128. package/src/email/providers/agentmail.ts +1 -1
  129. package/src/email/providers/index.ts +1 -1
  130. package/src/email/service.ts +1 -1
  131. package/src/gallery/default-gallery.ts +538 -0
  132. package/src/gallery/gallery-manifest.ts +5 -1
  133. package/src/influencer/client.ts +8 -6
  134. package/src/mcp/client.ts +1 -1
  135. package/src/media/avatar-router.ts +99 -0
  136. package/src/media/avatar-types.ts +60 -0
  137. package/src/media/managed-avatar-client.ts +189 -0
  138. package/src/memory/app-migration.ts +114 -0
  139. package/src/memory/app-store.ts +11 -0
  140. package/src/memory/qdrant-client.ts +1 -1
  141. package/src/messaging/providers/slack/client.ts +12 -2
  142. package/src/messaging/providers/sms/adapter.ts +6 -10
  143. package/src/migrations/data-layout.ts +8 -1
  144. package/src/oauth/token-persistence.ts +9 -6
  145. package/src/runtime/assistant-scope.ts +5 -0
  146. package/src/runtime/auth/route-policy.ts +4 -0
  147. package/src/runtime/channel-readiness-service.ts +9 -4
  148. package/src/runtime/gateway-internal-client.ts +11 -3
  149. package/src/runtime/http-server.ts +2 -0
  150. package/src/runtime/invite-redemption-service.ts +23 -13
  151. package/src/runtime/middleware/twilio-validation.ts +2 -2
  152. package/src/runtime/routes/app-routes.ts +131 -3
  153. package/src/runtime/routes/inbound-stages/verification-intercept.ts +3 -3
  154. package/src/runtime/routes/integration-routes.ts +2 -2
  155. package/src/runtime/routes/slack-share-routes.ts +235 -0
  156. package/src/runtime/routes/twilio-routes.ts +47 -34
  157. package/src/schedule/integration-status.ts +2 -3
  158. package/src/security/token-manager.ts +11 -3
  159. package/src/tools/apps/executors.ts +116 -8
  160. package/src/tools/browser/browser-manager.ts +30 -2
  161. package/src/tools/browser/chrome-cdp.ts +31 -3
  162. package/src/tools/credentials/vault.ts +9 -7
  163. package/src/tools/executor.ts +4 -0
  164. package/src/tools/system/avatar-generator.ts +55 -34
  165. package/src/twitter/client.ts +1 -1
  166. package/src/twitter/oauth-client.ts +31 -43
  167. package/src/twitter/router.ts +25 -23
  168. package/src/util/platform.ts +5 -0
  169. package/src/slack/slack-webhook.ts +0 -66
@@ -0,0 +1,280 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Mock dependencies — must be before importing the module under test
5
+ // ---------------------------------------------------------------------------
6
+
7
+ let mockApiKey: string | undefined = "test-api-key-123";
8
+ let mockBaseUrl = "https://platform.vellum.ai";
9
+ let mockPlatformEnvUrl = "https://env.vellum.ai";
10
+
11
+ mock.module("../security/secure-keys.js", () => ({
12
+ getSecureKey: (account: string) => {
13
+ if (account === "credential:vellum:assistant_api_key") return mockApiKey;
14
+ return undefined;
15
+ },
16
+ }));
17
+
18
+ mock.module("../config/loader.js", () => ({
19
+ getConfig: () => ({
20
+ platform: { baseUrl: mockBaseUrl },
21
+ }),
22
+ }));
23
+
24
+ mock.module("../config/env.js", () => ({
25
+ getPlatformBaseUrl: () => mockPlatformEnvUrl,
26
+ }));
27
+
28
+ mock.module("../util/logger.js", () => ({
29
+ getLogger: () => ({
30
+ debug: () => {},
31
+ info: () => {},
32
+ warn: () => {},
33
+ error: () => {},
34
+ }),
35
+ }));
36
+
37
+ // Mock global fetch
38
+ let lastFetchArgs: [string, RequestInit] | null = null;
39
+ let fetchResponse: { ok: boolean; status: number; json: () => Promise<unknown> } = {
40
+ ok: true,
41
+ status: 200,
42
+ json: async () => ({}),
43
+ };
44
+
45
+ const originalFetch = globalThis.fetch;
46
+ globalThis.fetch = (async (url: string, init: RequestInit) => {
47
+ lastFetchArgs = [url, init];
48
+ return fetchResponse;
49
+ }) as typeof globalThis.fetch;
50
+
51
+ // Import after mocking
52
+ import {
53
+ isManagedAvailable,
54
+ generateManagedAvatar,
55
+ getAssistantApiKey,
56
+ } from "../media/managed-avatar-client.js";
57
+ import {
58
+ ManagedAvatarError,
59
+ AVATAR_PROMPT_MAX_LENGTH,
60
+ AVATAR_MAX_DECODED_BYTES,
61
+ } from "../media/avatar-types.js";
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Helpers
65
+ // ---------------------------------------------------------------------------
66
+
67
+ function successResponse() {
68
+ return {
69
+ image: {
70
+ mime_type: "image/png",
71
+ data_base64: "iVBORw0KGgo=",
72
+ bytes: 1024,
73
+ sha256: "abc123",
74
+ },
75
+ usage: { billable: true, class_name: "avatar" },
76
+ generation_source: "managed",
77
+ profile: "default",
78
+ correlation_id: "test-corr-id",
79
+ };
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Tests
84
+ // ---------------------------------------------------------------------------
85
+
86
+ beforeEach(() => {
87
+ mockApiKey = "test-api-key-123";
88
+ mockBaseUrl = "https://platform.vellum.ai";
89
+ mockPlatformEnvUrl = "https://env.vellum.ai";
90
+ lastFetchArgs = null;
91
+ fetchResponse = {
92
+ ok: true,
93
+ status: 200,
94
+ json: async () => successResponse(),
95
+ };
96
+ });
97
+
98
+ describe("generateManagedAvatar", () => {
99
+ test("successful generation returns parsed response", async () => {
100
+ const result = await generateManagedAvatar("a friendly robot avatar");
101
+
102
+ expect(result.image.mime_type).toBe("image/png");
103
+ expect(result.image.data_base64).toBe("iVBORw0KGgo=");
104
+ expect(result.image.bytes).toBe(1024);
105
+ expect(result.generation_source).toBe("managed");
106
+ });
107
+
108
+ test("prompt exceeding max length throws ManagedAvatarError with code validation_error", async () => {
109
+ const longPrompt = "x".repeat(AVATAR_PROMPT_MAX_LENGTH + 1);
110
+
111
+ try {
112
+ await generateManagedAvatar(longPrompt);
113
+ expect(true).toBe(false); // should not reach here
114
+ } catch (err) {
115
+ expect(err).toBeInstanceOf(ManagedAvatarError);
116
+ const avatarErr = err as ManagedAvatarError;
117
+ expect(avatarErr.code).toBe("validation_error");
118
+ expect(avatarErr.subcode).toBe("prompt_too_long");
119
+ }
120
+ });
121
+
122
+ test("HTTP 429 response throws ManagedAvatarError with retryable true", async () => {
123
+ fetchResponse = {
124
+ ok: false,
125
+ status: 429,
126
+ json: async () => ({
127
+ code: "rate_limit",
128
+ subcode: "too_many_requests",
129
+ detail: "Rate limit exceeded",
130
+ retryable: true,
131
+ correlation_id: "corr-429",
132
+ }),
133
+ };
134
+
135
+ try {
136
+ await generateManagedAvatar("test prompt");
137
+ expect(true).toBe(false);
138
+ } catch (err) {
139
+ expect(err).toBeInstanceOf(ManagedAvatarError);
140
+ const avatarErr = err as ManagedAvatarError;
141
+ expect(avatarErr.retryable).toBe(true);
142
+ expect(avatarErr.statusCode).toBe(429);
143
+ }
144
+ });
145
+
146
+ test("HTTP 500 response throws ManagedAvatarError with upstream error details", async () => {
147
+ fetchResponse = {
148
+ ok: false,
149
+ status: 500,
150
+ json: async () => ({
151
+ code: "internal_error",
152
+ subcode: "server_fault",
153
+ detail: "Internal server error",
154
+ retryable: true,
155
+ correlation_id: "corr-500",
156
+ }),
157
+ };
158
+
159
+ try {
160
+ await generateManagedAvatar("test prompt");
161
+ expect(true).toBe(false);
162
+ } catch (err) {
163
+ expect(err).toBeInstanceOf(ManagedAvatarError);
164
+ const avatarErr = err as ManagedAvatarError;
165
+ expect(avatarErr.code).toBe("internal_error");
166
+ expect(avatarErr.subcode).toBe("server_fault");
167
+ expect(avatarErr.statusCode).toBe(500);
168
+ }
169
+ });
170
+
171
+ test("response with disallowed MIME type throws validation error", async () => {
172
+ fetchResponse = {
173
+ ok: true,
174
+ status: 200,
175
+ json: async () => ({
176
+ ...successResponse(),
177
+ image: { ...successResponse().image, mime_type: "image/gif" },
178
+ }),
179
+ };
180
+
181
+ try {
182
+ await generateManagedAvatar("test prompt");
183
+ expect(true).toBe(false);
184
+ } catch (err) {
185
+ expect(err).toBeInstanceOf(ManagedAvatarError);
186
+ const avatarErr = err as ManagedAvatarError;
187
+ expect(avatarErr.code).toBe("validation_error");
188
+ expect(avatarErr.subcode).toBe("disallowed_mime_type");
189
+ }
190
+ });
191
+
192
+ test("response with oversized bytes throws validation error", async () => {
193
+ fetchResponse = {
194
+ ok: true,
195
+ status: 200,
196
+ json: async () => ({
197
+ ...successResponse(),
198
+ image: { ...successResponse().image, bytes: AVATAR_MAX_DECODED_BYTES + 1 },
199
+ }),
200
+ };
201
+
202
+ try {
203
+ await generateManagedAvatar("test prompt");
204
+ expect(true).toBe(false);
205
+ } catch (err) {
206
+ expect(err).toBeInstanceOf(ManagedAvatarError);
207
+ const avatarErr = err as ManagedAvatarError;
208
+ expect(avatarErr.code).toBe("validation_error");
209
+ expect(avatarErr.subcode).toBe("oversized_image");
210
+ }
211
+ });
212
+
213
+ test("response with oversized base64 estimated decoded size throws validation error", async () => {
214
+ // Create a base64 string whose estimated decoded size exceeds the limit,
215
+ // even though the server-reported bytes field is under the limit
216
+ const oversizedBase64 = "A".repeat(Math.ceil((AVATAR_MAX_DECODED_BYTES + 100) * 4 / 3));
217
+ fetchResponse = {
218
+ ok: true,
219
+ status: 200,
220
+ json: async () => ({
221
+ ...successResponse(),
222
+ image: { ...successResponse().image, data_base64: oversizedBase64, bytes: 1024 },
223
+ }),
224
+ };
225
+
226
+ try {
227
+ await generateManagedAvatar("test prompt");
228
+ expect(true).toBe(false);
229
+ } catch (err) {
230
+ expect(err).toBeInstanceOf(ManagedAvatarError);
231
+ const avatarErr = err as ManagedAvatarError;
232
+ expect(avatarErr.code).toBe("validation_error");
233
+ expect(avatarErr.subcode).toBe("oversized_image");
234
+ }
235
+ });
236
+
237
+ test("Authorization header uses Api-Key prefix", async () => {
238
+ await generateManagedAvatar("test prompt");
239
+
240
+ expect(lastFetchArgs).not.toBeNull();
241
+ const headers = lastFetchArgs![1].headers as Record<string, string>;
242
+ expect(headers.Authorization).toBe("Api-Key test-api-key-123");
243
+ expect(headers.Authorization).not.toContain("Bearer");
244
+ });
245
+
246
+ test("Idempotency-Key header is present on every request", async () => {
247
+ await generateManagedAvatar("test prompt");
248
+
249
+ expect(lastFetchArgs).not.toBeNull();
250
+ const headers = lastFetchArgs![1].headers as Record<string, string>;
251
+ expect(headers["Idempotency-Key"]).toBeDefined();
252
+ expect(headers["Idempotency-Key"].length).toBeGreaterThan(0);
253
+ });
254
+
255
+ test("caller-provided idempotencyKey is used in the request header", async () => {
256
+ const customKey = "my-custom-idempotency-key-123";
257
+ await generateManagedAvatar("test prompt", { idempotencyKey: customKey });
258
+
259
+ expect(lastFetchArgs).not.toBeNull();
260
+ const headers = lastFetchArgs![1].headers as Record<string, string>;
261
+ expect(headers["Idempotency-Key"]).toBe(customKey);
262
+ });
263
+ });
264
+
265
+ describe("isManagedAvailable", () => {
266
+ test("returns false when API key is missing", () => {
267
+ mockApiKey = undefined;
268
+ expect(isManagedAvailable()).toBe(false);
269
+ });
270
+
271
+ test("returns false when base URL is missing", () => {
272
+ mockBaseUrl = "";
273
+ mockPlatformEnvUrl = "";
274
+ expect(isManagedAvailable()).toBe(false);
275
+ });
276
+
277
+ test("returns true when both API key and base URL are present", () => {
278
+ expect(isManagedAvailable()).toBe(true);
279
+ });
280
+ });
@@ -131,7 +131,7 @@ async function runMcpRemove(name: string) {
131
131
 
132
132
  // ── Tests ─────────────────────────────────────────────────────────────
133
133
 
134
- describe("vellum mcp list", () => {
134
+ describe("assistant mcp list", () => {
135
135
  beforeAll(() => {
136
136
  testDataDir = join(
137
137
  tmpdir(),
@@ -258,7 +258,7 @@ describe("vellum mcp list", () => {
258
258
  });
259
259
  });
260
260
 
261
- describe("vellum mcp add", () => {
261
+ describe("assistant mcp add", () => {
262
262
  beforeAll(() => {
263
263
  testDataDir = join(
264
264
  tmpdir(),
@@ -396,7 +396,7 @@ describe("vellum mcp add", () => {
396
396
  });
397
397
  });
398
398
 
399
- describe("vellum mcp remove", () => {
399
+ describe("assistant mcp remove", () => {
400
400
  beforeAll(() => {
401
401
  testDataDir = join(
402
402
  tmpdir(),
@@ -0,0 +1,203 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ import { Command } from "commander";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Mock state
7
+ // ---------------------------------------------------------------------------
8
+
9
+ let mockWithValidToken: <T>(
10
+ service: string,
11
+ cb: (token: string) => Promise<T>,
12
+ ) => Promise<T>;
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Mock token-manager
16
+ // ---------------------------------------------------------------------------
17
+
18
+ mock.module("../security/token-manager.js", () => ({
19
+ withValidToken: <T>(
20
+ service: string,
21
+ cb: (token: string) => Promise<T>,
22
+ ): Promise<T> => mockWithValidToken(service, cb),
23
+ // Stubs for any transitive imports that reference other exports:
24
+ TokenExpiredError: class TokenExpiredError extends Error {
25
+ constructor(
26
+ public readonly service: string,
27
+ message?: string,
28
+ ) {
29
+ super(message ?? `Token expired for "${service}".`);
30
+ this.name = "TokenExpiredError";
31
+ }
32
+ },
33
+ }));
34
+
35
+ // Stub out transitive dependencies that token-manager would normally pull in
36
+ mock.module("../security/secure-keys.js", () => ({
37
+ getSecureKey: () => undefined,
38
+ setSecureKey: () => true,
39
+ getSecureKeyAsync: async () => undefined,
40
+ setSecureKeyAsync: async () => true,
41
+ deleteSecureKey: () => "not-found",
42
+ }));
43
+
44
+ mock.module("../tools/credentials/metadata-store.js", () => ({
45
+ getCredentialMetadata: () => undefined,
46
+ upsertCredentialMetadata: () => ({}),
47
+ listCredentialMetadata: () => [],
48
+ }));
49
+
50
+ mock.module("../util/logger.js", () => ({
51
+ getLogger: () => ({
52
+ info: () => {},
53
+ warn: () => {},
54
+ error: () => {},
55
+ debug: () => {},
56
+ }),
57
+ getCliLogger: () => ({
58
+ info: () => {},
59
+ warn: () => {},
60
+ error: () => {},
61
+ debug: () => {},
62
+ }),
63
+ }));
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Import the module under test (after mocks are registered)
67
+ // ---------------------------------------------------------------------------
68
+
69
+ const { registerOAuthCommand } = await import("../cli/oauth.js");
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Test helper
73
+ // ---------------------------------------------------------------------------
74
+
75
+ async function runCli(
76
+ args: string[],
77
+ ): Promise<{ exitCode: number; stdout: string }> {
78
+ const originalStdoutWrite = process.stdout.write.bind(process.stdout);
79
+ const originalStderrWrite = process.stderr.write.bind(process.stderr);
80
+ const stdoutChunks: string[] = [];
81
+
82
+ process.stdout.write = ((chunk: unknown) => {
83
+ stdoutChunks.push(typeof chunk === "string" ? chunk : String(chunk));
84
+ return true;
85
+ }) as typeof process.stdout.write;
86
+
87
+ process.stderr.write = (() => true) as typeof process.stderr.write;
88
+
89
+ process.exitCode = 0;
90
+
91
+ try {
92
+ const program = new Command();
93
+ program.exitOverride();
94
+ program.configureOutput({
95
+ writeErr: () => {},
96
+ writeOut: (str: string) => stdoutChunks.push(str),
97
+ });
98
+ registerOAuthCommand(program);
99
+ await program.parseAsync(["node", "assistant", "oauth", ...args]);
100
+ } catch {
101
+ if (process.exitCode === 0) process.exitCode = 1;
102
+ } finally {
103
+ process.stdout.write = originalStdoutWrite;
104
+ process.stderr.write = originalStderrWrite;
105
+ }
106
+
107
+ const exitCode = process.exitCode ?? 0;
108
+ process.exitCode = 0;
109
+
110
+ return {
111
+ exitCode,
112
+ stdout: stdoutChunks.join(""),
113
+ };
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Tests
118
+ // ---------------------------------------------------------------------------
119
+
120
+ describe("assistant oauth token", () => {
121
+ beforeEach(() => {
122
+ mockWithValidToken = async (_service, cb) => cb("mock-access-token-xyz");
123
+ });
124
+
125
+ test("prints bare token in human mode", async () => {
126
+ const { exitCode, stdout } = await runCli(["token", "twitter"]);
127
+ expect(exitCode).toBe(0);
128
+ expect(stdout).toBe("mock-access-token-xyz\n");
129
+ });
130
+
131
+ test("prints JSON in --json mode", async () => {
132
+ const { exitCode, stdout } = await runCli(["token", "twitter", "--json"]);
133
+ expect(exitCode).toBe(0);
134
+ const parsed = JSON.parse(stdout);
135
+ expect(parsed).toEqual({ ok: true, token: "mock-access-token-xyz" });
136
+ });
137
+
138
+ test("qualifies service name with integration: prefix", async () => {
139
+ let capturedService: string | undefined;
140
+ mockWithValidToken = async (service, cb) => {
141
+ capturedService = service;
142
+ return cb("tok");
143
+ };
144
+
145
+ await runCli(["token", "twitter"]);
146
+ expect(capturedService).toBe("integration:twitter");
147
+ });
148
+
149
+ test("works with other service names", async () => {
150
+ let capturedService: string | undefined;
151
+ mockWithValidToken = async (service, cb) => {
152
+ capturedService = service;
153
+ return cb("gmail-token");
154
+ };
155
+
156
+ const { exitCode, stdout } = await runCli(["token", "gmail"]);
157
+ expect(exitCode).toBe(0);
158
+ expect(stdout).toBe("gmail-token\n");
159
+ expect(capturedService).toBe("integration:gmail");
160
+ });
161
+
162
+ test("exits 1 when no token exists", async () => {
163
+ mockWithValidToken = async () => {
164
+ throw new Error(
165
+ 'No access token found for "integration:twitter". Authorization required.',
166
+ );
167
+ };
168
+
169
+ const { exitCode, stdout } = await runCli(["token", "twitter", "--json"]);
170
+ expect(exitCode).toBe(1);
171
+ const parsed = JSON.parse(stdout);
172
+ expect(parsed.ok).toBe(false);
173
+ expect(parsed.error).toContain("No access token found");
174
+ });
175
+
176
+ test("exits 1 when refresh fails", async () => {
177
+ mockWithValidToken = async () => {
178
+ throw new Error(
179
+ 'Token refresh failed for "integration:twitter": invalid_grant.',
180
+ );
181
+ };
182
+
183
+ const { exitCode, stdout } = await runCli(["token", "twitter", "--json"]);
184
+ expect(exitCode).toBe(1);
185
+ const parsed = JSON.parse(stdout);
186
+ expect(parsed.ok).toBe(false);
187
+ expect(parsed.error).toContain("Token refresh failed");
188
+ });
189
+
190
+ test("returns refreshed token transparently", async () => {
191
+ // Simulate withValidToken refreshing and returning a new token
192
+ mockWithValidToken = async (_service, cb) => cb("refreshed-new-token");
193
+
194
+ const { exitCode, stdout } = await runCli(["token", "twitter"]);
195
+ expect(exitCode).toBe(0);
196
+ expect(stdout).toBe("refreshed-new-token\n");
197
+ });
198
+
199
+ test("missing service argument exits non-zero", async () => {
200
+ const { exitCode } = await runCli(["token"]);
201
+ expect(exitCode).not.toBe(0);
202
+ });
203
+ });
@@ -192,7 +192,7 @@ import {
192
192
  import { setVoiceBridgeDeps } from "../calls/voice-session-bridge.js";
193
193
  import {
194
194
  createGuardianBinding,
195
- upsertMember,
195
+ upsertContactChannel,
196
196
  } from "../contacts/contacts-write.js";
197
197
  import {
198
198
  listCanonicalGuardianRequests,
@@ -291,7 +291,7 @@ function resetTables() {
291
291
  }
292
292
 
293
293
  function addTrustedVoiceContact(phoneNumber: string): void {
294
- upsertMember({
294
+ upsertContactChannel({
295
295
  sourceChannel: "voice",
296
296
  externalUserId: phoneNumber,
297
297
  externalChatId: phoneNumber,
@@ -2452,7 +2452,7 @@ describe("relay-server", () => {
2452
2452
  });
2453
2453
 
2454
2454
  // Create a blocked member
2455
- upsertMember({
2455
+ upsertContactChannel({
2456
2456
  sourceChannel: "voice",
2457
2457
  externalUserId: "+15558881111",
2458
2458
  externalChatId: "+15558881111",
@@ -29,23 +29,30 @@ mock.module("../util/logger.js", () => ({
29
29
 
30
30
  // Track keychain writes
31
31
  const storedKeys = new Map<string, string>();
32
- mock.module("../security/secure-keys.js", () => ({
33
- getSecureKey: (key: string) => storedKeys.get(key) ?? null,
34
- setSecureKey: (key: string, value: string) => {
32
+ mock.module("../security/secure-keys.js", () => {
33
+ const syncSet = (key: string, value: string) => {
35
34
  storedKeys.set(key, value);
36
35
  return true;
37
- },
38
- deleteSecureKey: (key: string) => {
36
+ };
37
+ const syncDelete = (key: string) => {
39
38
  if (storedKeys.has(key)) {
40
39
  storedKeys.delete(key);
41
- return "deleted";
40
+ return "deleted" as const;
42
41
  }
43
- return "not-found";
44
- },
45
- listSecureKeys: () => [],
46
- getBackendType: () => "encrypted",
47
- isDowngradedFromKeychain: () => false,
48
- }));
42
+ return "not-found" as const;
43
+ };
44
+ return {
45
+ getSecureKey: (key: string) => storedKeys.get(key) ?? null,
46
+ setSecureKey: syncSet,
47
+ setSecureKeyAsync: async (key: string, value: string) =>
48
+ syncSet(key, value),
49
+ deleteSecureKey: syncDelete,
50
+ deleteSecureKeyAsync: async (key: string) => syncDelete(key),
51
+ listSecureKeys: () => [],
52
+ getBackendType: () => "encrypted",
53
+ isDowngradedFromKeychain: () => false,
54
+ };
55
+ });
49
56
 
50
57
  mock.module("./metadata-store.js", () => ({
51
58
  upsertCredentialMetadata: () => {},
@@ -302,6 +302,84 @@ describe("secure-keys", () => {
302
302
  });
303
303
  });
304
304
 
305
+ // -----------------------------------------------------------------------
306
+ // Stale-value prevention — broker-first reads after credential updates
307
+ // -----------------------------------------------------------------------
308
+ describe("stale-value prevention", () => {
309
+ test("setSecureKeyAsync updates broker so broker-first read returns new value", async () => {
310
+ mockBrokerAvailable = true;
311
+ // Simulate broker holding an old value
312
+ mockBrokerStore.set("api-key", "old-broker-value");
313
+ setSecureKey("api-key", "old-encrypted-value");
314
+
315
+ // Update via async path (writes both broker + encrypted)
316
+ const ok = await setSecureKeyAsync("api-key", "new-value");
317
+ expect(ok).toBe(true);
318
+
319
+ // Broker-first read should return the new value, not stale old value
320
+ const value = await getSecureKeyAsync("api-key");
321
+ expect(value).toBe("new-value");
322
+ // Both stores should agree
323
+ expect(mockBrokerStore.get("api-key")).toBe("new-value");
324
+ expect(getSecureKey("api-key")).toBe("new-value");
325
+ });
326
+
327
+ test("deleteSecureKeyAsync removes from broker so broker-first read falls through", async () => {
328
+ mockBrokerAvailable = true;
329
+ mockBrokerStore.set("api-key", "old-broker-value");
330
+ setSecureKey("api-key", "old-encrypted-value");
331
+
332
+ // Delete via async path (deletes from both broker + encrypted)
333
+ const result = await deleteSecureKeyAsync("api-key");
334
+ expect(result).toBe("deleted");
335
+
336
+ // Broker-first read should not find the key in either store
337
+ const value = await getSecureKeyAsync("api-key");
338
+ expect(value).toBeUndefined();
339
+ });
340
+
341
+ test("sync setSecureKey does NOT update broker — stale read demonstrates the problem", async () => {
342
+ mockBrokerAvailable = true;
343
+ mockBrokerStore.set("api-key", "old-broker-value");
344
+
345
+ // Sync write only updates encrypted store, NOT broker
346
+ setSecureKey("api-key", "new-encrypted-value");
347
+
348
+ // Broker-first read still returns the stale broker value
349
+ const value = await getSecureKeyAsync("api-key");
350
+ expect(value).toBe("old-broker-value");
351
+ // This is the exact bug that async migration fixes
352
+ });
353
+
354
+ test("setSecureKeyAsync failure leaves both stores unchanged", async () => {
355
+ mockBrokerAvailable = true;
356
+ mockBrokerSetError = true;
357
+ mockBrokerStore.set("api-key", "original-value");
358
+ setSecureKey("api-key", "original-value");
359
+
360
+ const ok = await setSecureKeyAsync("api-key", "new-value");
361
+ expect(ok).toBe(false);
362
+
363
+ // Both stores should retain original value — no partial update
364
+ expect(mockBrokerStore.get("api-key")).toBe("original-value");
365
+ expect(getSecureKey("api-key")).toBe("original-value");
366
+ });
367
+
368
+ test("deleteSecureKeyAsync failure leaves both stores unchanged", async () => {
369
+ mockBrokerAvailable = true;
370
+ mockBrokerDelError = true;
371
+ mockBrokerStore.set("api-key", "value");
372
+ setSecureKey("api-key", "value");
373
+
374
+ const result = await deleteSecureKeyAsync("api-key");
375
+ expect(result).toBe("error");
376
+
377
+ // Both stores should retain the key — no partial deletion
378
+ expect(mockBrokerStore.has("api-key")).toBe(true);
379
+ expect(getSecureKey("api-key")).toBe("value");
380
+ });
381
+ });
382
+
305
383
  // -----------------------------------------------------------------------
306
384
  // _setBackend / _resetBackend (no-ops kept for test compat)
307
385
  // -----------------------------------------------------------------------
@@ -26,7 +26,10 @@ const metadataByKey = new Map<
26
26
  mock.module("../security/secure-keys.js", () => ({
27
27
  getSecureKey: () => undefined,
28
28
  setSecureKey: setSecureKeyMock,
29
+ setSecureKeyAsync: async (key?: string, value?: string) =>
30
+ setSecureKeyMock(key, value),
29
31
  deleteSecureKey: () => "deleted",
32
+ deleteSecureKeyAsync: async () => "deleted" as const,
30
33
  listSecureKeys: () => [],
31
34
  getBackendType: () => null,
32
35
  isDowngradedFromKeychain: () => false,