@vellumai/assistant 0.5.11 → 0.5.12

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 (188) hide show
  1. package/Dockerfile +1 -0
  2. package/docs/architecture/integrations.md +34 -32
  3. package/node_modules/@vellumai/ces-contracts/src/__tests__/grants.test.ts +7 -7
  4. package/node_modules/@vellumai/ces-contracts/src/handles.ts +5 -4
  5. package/node_modules/@vellumai/credential-storage/src/index.ts +1 -1
  6. package/openapi.yaml +87 -9
  7. package/package.json +1 -1
  8. package/src/__tests__/catalog-cache.test.ts +164 -0
  9. package/src/__tests__/catalog-search.test.ts +61 -0
  10. package/src/__tests__/cli-command-risk-guard.test.ts +181 -6
  11. package/src/__tests__/conversation-delete-schedule-cleanup.test.ts +396 -0
  12. package/src/__tests__/conversation-error.test.ts +3 -2
  13. package/src/__tests__/credential-security-invariants.test.ts +9 -15
  14. package/src/__tests__/credential-vault-unit.test.ts +32 -34
  15. package/src/__tests__/credential-vault.test.ts +25 -33
  16. package/src/__tests__/credentials-cli.test.ts +3 -3
  17. package/src/__tests__/daemon-credential-client.test.ts +2 -2
  18. package/src/__tests__/host-bash-proxy.test.ts +79 -0
  19. package/src/__tests__/host-cu-proxy.test.ts +90 -0
  20. package/src/__tests__/host-file-proxy.test.ts +89 -0
  21. package/src/__tests__/integration-status.test.ts +5 -5
  22. package/src/__tests__/list-messages-attachments.test.ts +171 -0
  23. package/src/__tests__/mcp-abort-signal.test.ts +205 -0
  24. package/src/__tests__/messaging-send-tool.test.ts +5 -5
  25. package/src/__tests__/notification-telegram-adapter.test.ts +125 -0
  26. package/src/__tests__/oauth-cli.test.ts +126 -119
  27. package/src/__tests__/oauth-provider-profiles.test.ts +55 -20
  28. package/src/__tests__/oauth-scope-policy.test.ts +4 -6
  29. package/src/__tests__/onboarding-template-contract.test.ts +2 -2
  30. package/src/__tests__/secret-routes-managed-proxy.test.ts +78 -0
  31. package/src/__tests__/secure-keys-managed-failover.test.ts +73 -0
  32. package/src/__tests__/skills-uninstall.test.ts +2 -2
  33. package/src/__tests__/slack-messaging-token-resolution.test.ts +22 -24
  34. package/src/__tests__/slack-share-routes.test.ts +5 -5
  35. package/src/__tests__/system-prompt.test.ts +39 -0
  36. package/src/__tests__/workspace-migration-backfill-installation-id.test.ts +5 -4
  37. package/src/cli/AGENTS.md +47 -7
  38. package/src/cli/commands/browser-relay.ts +2 -17
  39. package/src/cli/commands/contacts.ts +6 -4
  40. package/src/cli/commands/conversations.ts +13 -1
  41. package/src/cli/commands/credential-execution.ts +16 -1
  42. package/src/cli/commands/credentials.ts +2 -8
  43. package/src/cli/commands/oauth/__tests__/connect.test.ts +29 -108
  44. package/src/cli/commands/oauth/__tests__/disconnect.test.ts +13 -87
  45. package/src/cli/commands/oauth/__tests__/mode.test.ts +22 -69
  46. package/src/cli/commands/oauth/__tests__/ping.test.ts +20 -79
  47. package/src/cli/commands/oauth/__tests__/providers-delete.test.ts +574 -0
  48. package/src/cli/commands/oauth/__tests__/providers-update.test.ts +416 -0
  49. package/src/cli/commands/oauth/__tests__/status.test.ts +12 -40
  50. package/src/cli/commands/oauth/__tests__/token.test.ts +3 -50
  51. package/src/cli/commands/oauth/apps.ts +63 -44
  52. package/src/cli/commands/oauth/connect.ts +187 -155
  53. package/src/cli/commands/oauth/disconnect.ts +27 -75
  54. package/src/cli/commands/oauth/index.ts +36 -46
  55. package/src/cli/commands/oauth/mode.ts +22 -34
  56. package/src/cli/commands/oauth/ping.ts +19 -45
  57. package/src/cli/commands/oauth/providers.ts +569 -62
  58. package/src/cli/commands/oauth/request.ts +36 -48
  59. package/src/cli/commands/oauth/shared.ts +1 -19
  60. package/src/cli/commands/oauth/status.ts +14 -25
  61. package/src/cli/commands/oauth/token.ts +25 -34
  62. package/src/cli/commands/platform/connect.ts +104 -0
  63. package/src/cli/commands/platform/disconnect.ts +118 -0
  64. package/src/cli/commands/{platform.ts → platform/index.ts} +108 -38
  65. package/src/cli/commands/sequence.ts +5 -4
  66. package/src/cli/commands/shotgun.ts +16 -0
  67. package/src/cli/commands/skills.ts +173 -41
  68. package/src/cli/commands/usage.ts +5 -11
  69. package/src/cli/lib/daemon-credential-client.ts +22 -38
  70. package/src/cli/program.ts +1 -1
  71. package/src/config/assistant-feature-flags.ts +3 -7
  72. package/src/config/bundled-skills/contacts/tools/google-contacts.ts +1 -1
  73. package/src/config/bundled-skills/conversations/SKILL.md +20 -0
  74. package/src/config/bundled-skills/conversations/TOOLS.json +23 -0
  75. package/src/config/bundled-skills/conversations/tools/rename-conversation.ts +66 -0
  76. package/src/config/bundled-skills/gmail/SKILL.md +13 -13
  77. package/src/config/bundled-skills/gmail/tools/gmail-archive.ts +3 -3
  78. package/src/config/bundled-skills/gmail/tools/gmail-attachments.ts +2 -2
  79. package/src/config/bundled-skills/gmail/tools/gmail-draft.ts +1 -1
  80. package/src/config/bundled-skills/gmail/tools/gmail-filters.ts +1 -1
  81. package/src/config/bundled-skills/gmail/tools/gmail-follow-up.ts +1 -1
  82. package/src/config/bundled-skills/gmail/tools/gmail-forward.ts +1 -1
  83. package/src/config/bundled-skills/gmail/tools/gmail-label.ts +2 -2
  84. package/src/config/bundled-skills/gmail/tools/gmail-outreach-scan.ts +1 -1
  85. package/src/config/bundled-skills/gmail/tools/gmail-send-draft.ts +1 -1
  86. package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +1 -1
  87. package/src/config/bundled-skills/gmail/tools/gmail-trash.ts +1 -1
  88. package/src/config/bundled-skills/gmail/tools/gmail-unsubscribe.ts +1 -1
  89. package/src/config/bundled-skills/gmail/tools/gmail-vacation.ts +1 -1
  90. package/src/config/bundled-skills/google-calendar/SKILL.md +10 -4
  91. package/src/config/bundled-skills/google-calendar/tools/shared.ts +1 -1
  92. package/src/config/bundled-skills/messaging/SKILL.md +7 -7
  93. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -2
  94. package/src/config/bundled-skills/messaging/tools/shared.ts +5 -6
  95. package/src/config/bundled-tool-registry.ts +5 -0
  96. package/src/config/feature-flag-registry.json +1 -1
  97. package/src/credential-execution/client.ts +1 -1
  98. package/src/daemon/conversation-agent-loop.ts +2 -0
  99. package/src/daemon/conversation-error.ts +36 -6
  100. package/src/daemon/conversation-messaging.ts +9 -0
  101. package/src/daemon/conversation-runtime-assembly.ts +33 -0
  102. package/src/daemon/conversation-surfaces.ts +120 -14
  103. package/src/daemon/conversation.ts +5 -0
  104. package/src/daemon/handlers/skills.ts +148 -3
  105. package/src/daemon/host-bash-proxy.ts +16 -0
  106. package/src/daemon/host-cu-proxy.ts +16 -0
  107. package/src/daemon/host-file-proxy.ts +16 -0
  108. package/src/daemon/lifecycle.ts +47 -1
  109. package/src/daemon/message-types/conversations.ts +1 -0
  110. package/src/daemon/message-types/guardian-actions.ts +2 -0
  111. package/src/daemon/message-types/host-bash.ts +6 -1
  112. package/src/daemon/message-types/host-cu.ts +6 -1
  113. package/src/daemon/message-types/host-file.ts +6 -1
  114. package/src/daemon/message-types/integrations.ts +0 -1
  115. package/src/daemon/server.ts +29 -2
  116. package/src/hooks/cli.ts +74 -0
  117. package/src/inbound/platform-callback-registration.ts +7 -12
  118. package/src/mcp/client.ts +6 -1
  119. package/src/mcp/manager.ts +2 -1
  120. package/src/memory/conversation-crud.ts +92 -3
  121. package/src/memory/conversation-key-store.ts +26 -0
  122. package/src/memory/db-init.ts +16 -0
  123. package/src/memory/migrations/196-messages-conversation-created-at-index.ts +9 -0
  124. package/src/memory/migrations/196-strip-integration-prefix-from-provider-keys.ts +186 -0
  125. package/src/memory/migrations/197-oauth-providers-behavior-columns.ts +29 -0
  126. package/src/memory/migrations/198-drop-setup-skill-id-column.ts +11 -0
  127. package/src/memory/migrations/index.ts +4 -0
  128. package/src/memory/migrations/registry.ts +8 -0
  129. package/src/memory/schema/oauth.ts +11 -0
  130. package/src/messaging/provider.ts +13 -12
  131. package/src/messaging/providers/gmail/adapter.ts +44 -35
  132. package/src/messaging/providers/slack/adapter.ts +63 -33
  133. package/src/messaging/providers/telegram-bot/adapter.ts +6 -8
  134. package/src/messaging/providers/whatsapp/adapter.ts +6 -8
  135. package/src/notifications/adapters/telegram.ts +78 -2
  136. package/src/oauth/__tests__/identity-verifier.test.ts +464 -0
  137. package/src/oauth/byo-connection.test.ts +22 -24
  138. package/src/oauth/connect-orchestrator.ts +37 -76
  139. package/src/oauth/connect-types.ts +7 -65
  140. package/src/oauth/connection-resolver.test.ts +13 -13
  141. package/src/oauth/connection-resolver.ts +3 -4
  142. package/src/oauth/identity-verifier.ts +177 -0
  143. package/src/oauth/oauth-store.ts +228 -3
  144. package/src/oauth/platform-connection.test.ts +56 -6
  145. package/src/oauth/platform-connection.ts +8 -1
  146. package/src/oauth/seed-providers.ts +247 -34
  147. package/src/permissions/checker.ts +127 -1
  148. package/src/prompts/system-prompt.ts +43 -9
  149. package/src/prompts/templates/BOOTSTRAP.md +16 -5
  150. package/src/providers/anthropic/client.ts +2 -33
  151. package/src/runtime/guardian-action-service.ts +7 -2
  152. package/src/runtime/http-server.ts +5 -3
  153. package/src/runtime/http-types.ts +8 -1
  154. package/src/runtime/routes/conversation-management-routes.ts +31 -0
  155. package/src/runtime/routes/conversation-routes.ts +79 -4
  156. package/src/runtime/routes/guardian-action-routes.ts +15 -2
  157. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +21 -8
  158. package/src/runtime/routes/integrations/slack/share.ts +1 -1
  159. package/src/runtime/routes/oauth-apps.ts +2 -1
  160. package/src/runtime/routes/secret-routes.ts +36 -13
  161. package/src/runtime/routes/settings-routes.ts +12 -19
  162. package/src/runtime/routes/skills-routes.ts +45 -4
  163. package/src/schedule/integration-status.ts +2 -2
  164. package/src/security/ces-rpc-credential-backend.ts +19 -16
  165. package/src/security/oauth-completion-page.ts +153 -0
  166. package/src/security/oauth2.ts +3 -17
  167. package/src/security/secure-keys.ts +207 -7
  168. package/src/security/token-manager.ts +3 -6
  169. package/src/signals/bash.ts +6 -1
  170. package/src/skills/catalog-cache.ts +44 -0
  171. package/src/skills/catalog-search.ts +18 -0
  172. package/src/tools/credentials/post-connect-hooks.ts +1 -1
  173. package/src/tools/credentials/vault.ts +34 -45
  174. package/src/tools/host-terminal/host-shell.ts +16 -3
  175. package/src/tools/mcp/mcp-tool-factory.ts +2 -1
  176. package/src/tools/skills/sandbox-runner.ts +16 -3
  177. package/src/tools/terminal/shell.ts +16 -3
  178. package/src/util/logger.ts +11 -1
  179. package/src/util/sentry-log-stream.ts +51 -0
  180. package/src/watcher/providers/github.ts +2 -2
  181. package/src/watcher/providers/gmail.ts +1 -1
  182. package/src/watcher/providers/google-calendar.ts +1 -1
  183. package/src/watcher/providers/linear.ts +2 -2
  184. package/src/workspace/migrations/011-backfill-installation-id.ts +5 -3
  185. package/src/workspace/migrations/020-rename-oauth-skill-dirs.ts +119 -0
  186. package/src/workspace/migrations/registry.ts +2 -0
  187. package/src/cli/commands/oauth/connections.ts +0 -255
  188. package/src/oauth/provider-behaviors.ts +0 -634
@@ -1,7 +1,8 @@
1
- // Guard test: assistant CLI commands must always classify as Low risk.
1
+ // Guard test: assistant CLI commands must classify at the expected risk level.
2
2
  //
3
- // The assistant uses its own CLI tools during normal operation. If these
4
- // commands require user approval, it blocks autonomous assistant workflows.
3
+ // The assistant uses its own CLI tools during normal operation. Most commands
4
+ // should be Low risk so they don't block autonomous workflows. Certain
5
+ // sensitive subcommands are intentionally elevated to Medium or High.
5
6
  // See #18982 / #18998 for the regression that motivated this guard.
6
7
 
7
8
  import { mkdtempSync } from "node:fs";
@@ -60,10 +61,10 @@ function expectLowRisk(command: string, actual: RiskLevel): void {
60
61
  if (actual !== RiskLevel.Low) {
61
62
  throw new Error(
62
63
  `"${command}" classified as ${actual} instead of Low. ` +
63
- `assistant CLI commands must always be Low risk — the assistant ` +
64
+ `assistant CLI commands must be Low risk by default — the assistant ` +
64
65
  `uses its own CLI during normal operation. If you need risk ` +
65
- `escalation for specific subcommands, add them to an allowlist ` +
66
- `in this guard test with justification.`,
66
+ `escalation for specific subcommands, add them to the elevated ` +
67
+ `risk tests in this guard test with justification.`,
67
68
  );
68
69
  }
69
70
  expect(actual).toBe(RiskLevel.Low);
@@ -86,6 +87,9 @@ describe("CLI command risk guard: assistant commands", () => {
86
87
 
87
88
  test("all assistant CLI subcommands classify as Low risk", async () => {
88
89
  for (const subcommand of ASSISTANT_SUBCOMMANDS) {
90
+ // Subcommands with elevated children are tested separately below.
91
+ // The bare top-level subcommand (e.g. `assistant oauth`) is still
92
+ // expected to be Low.
89
93
  const command = `assistant ${subcommand}`;
90
94
  const risk = await classifyRisk("bash", { command });
91
95
  expectLowRisk(command, risk);
@@ -110,3 +114,174 @@ describe("CLI command risk guard: assistant commands", () => {
110
114
  }
111
115
  });
112
116
  });
117
+
118
+ // Sensitive subcommands that are intentionally elevated above Low risk.
119
+ // Each entry documents why the elevation is necessary.
120
+
121
+ describe("CLI command risk guard: elevated assistant subcommands", () => {
122
+ test("assistant oauth token is High risk (exposes raw tokens)", async () => {
123
+ const risk = await classifyRisk("bash", {
124
+ command: "assistant oauth token",
125
+ });
126
+ expect(risk).toBe(RiskLevel.High);
127
+ });
128
+
129
+ test("assistant oauth mode --set is High risk (changes auth mode)", async () => {
130
+ const risk = await classifyRisk("bash", {
131
+ command: "assistant oauth mode --set managed",
132
+ });
133
+ expect(risk).toBe(RiskLevel.High);
134
+ });
135
+
136
+ test("assistant oauth mode --set=value is High risk (equals syntax)", async () => {
137
+ const risk = await classifyRisk("bash", {
138
+ command: "assistant oauth mode google --set=managed",
139
+ });
140
+ expect(risk).toBe(RiskLevel.High);
141
+ });
142
+
143
+ test("assistant oauth mode without --set is Low risk (read-only)", async () => {
144
+ const risk = await classifyRisk("bash", {
145
+ command: "assistant oauth mode",
146
+ });
147
+ expect(risk).toBe(RiskLevel.Low);
148
+ });
149
+
150
+ test("assistant credentials reveal is High risk (exposes secrets)", async () => {
151
+ const risk = await classifyRisk("bash", {
152
+ command: "assistant credentials reveal",
153
+ });
154
+ expect(risk).toBe(RiskLevel.High);
155
+ });
156
+
157
+ test("assistant oauth request is Medium risk (initiates OAuth flow)", async () => {
158
+ const risk = await classifyRisk("bash", {
159
+ command: "assistant oauth request",
160
+ });
161
+ expect(risk).toBe(RiskLevel.Medium);
162
+ });
163
+
164
+ test("assistant oauth connect is Medium risk (modifies OAuth connections)", async () => {
165
+ const risk = await classifyRisk("bash", {
166
+ command: "assistant oauth connect",
167
+ });
168
+ expect(risk).toBe(RiskLevel.Medium);
169
+ });
170
+
171
+ test("assistant oauth disconnect is Medium risk (removes OAuth connections)", async () => {
172
+ const risk = await classifyRisk("bash", {
173
+ command: "assistant oauth disconnect",
174
+ });
175
+ expect(risk).toBe(RiskLevel.Medium);
176
+ });
177
+
178
+ test("--help on elevated subcommands is Low risk (read-only)", async () => {
179
+ const helpCommands = [
180
+ "assistant oauth token --help",
181
+ "assistant oauth mode --set --help",
182
+ "assistant credentials reveal --help",
183
+ "assistant oauth request --help",
184
+ "assistant oauth connect --help",
185
+ "assistant oauth disconnect -h",
186
+ ];
187
+
188
+ for (const command of helpCommands) {
189
+ const risk = await classifyRisk("bash", { command });
190
+ expectLowRisk(command, risk);
191
+ }
192
+ });
193
+
194
+ test("--help after -- option terminator does not downgrade risk", async () => {
195
+ const risk = await classifyRisk("bash", {
196
+ command: "assistant oauth token -- --help",
197
+ });
198
+ expect(risk).toBe(RiskLevel.High);
199
+ });
200
+
201
+ test("non-sensitive oauth subcommands remain Low risk", async () => {
202
+ const lowRiskOauthCommands = [
203
+ "assistant oauth apps",
204
+ "assistant oauth apps list",
205
+ "assistant oauth providers",
206
+ "assistant oauth status",
207
+ ];
208
+
209
+ for (const command of lowRiskOauthCommands) {
210
+ const risk = await classifyRisk("bash", { command });
211
+ expectLowRisk(command, risk);
212
+ }
213
+ });
214
+
215
+ test("non-sensitive credentials subcommands remain Low risk", async () => {
216
+ const lowRiskCredCommands = [
217
+ "assistant credentials",
218
+ "assistant credentials list",
219
+ ];
220
+
221
+ for (const command of lowRiskCredCommands) {
222
+ const risk = await classifyRisk("bash", { command });
223
+ expectLowRisk(command, risk);
224
+ }
225
+ });
226
+ });
227
+
228
+ describe("CLI command risk guard: wrapper program propagation", () => {
229
+ test("env assistant oauth token is High risk", async () => {
230
+ const risk = await classifyRisk("bash", {
231
+ command: "env assistant oauth token",
232
+ });
233
+ expect(risk).toBe(RiskLevel.High);
234
+ });
235
+
236
+ test("nice assistant credentials reveal is High risk", async () => {
237
+ const risk = await classifyRisk("bash", {
238
+ command: "nice assistant credentials reveal",
239
+ });
240
+ expect(risk).toBe(RiskLevel.High);
241
+ });
242
+
243
+ test("timeout 30 assistant oauth request is Medium risk", async () => {
244
+ const risk = await classifyRisk("bash", {
245
+ command: "timeout 30 assistant oauth request",
246
+ });
247
+ expect(risk).toBe(RiskLevel.Medium);
248
+ });
249
+
250
+ test("timeout 30 assistant oauth token is High risk", async () => {
251
+ const risk = await classifyRisk("bash", {
252
+ command: "timeout 30 assistant oauth token",
253
+ });
254
+ expect(risk).toBe(RiskLevel.High);
255
+ });
256
+
257
+ test("timeout 30 git push is Medium risk", async () => {
258
+ const risk = await classifyRisk("bash", {
259
+ command: "timeout 30 git push",
260
+ });
261
+ expect(risk).toBe(RiskLevel.Medium);
262
+ });
263
+
264
+ test("timeout 30 git status is Low risk", async () => {
265
+ const risk = await classifyRisk("bash", {
266
+ command: "timeout 30 git status",
267
+ });
268
+ expectLowRisk("timeout 30 git status", risk);
269
+ });
270
+
271
+ test("env assistant config is Low risk", async () => {
272
+ const risk = await classifyRisk("bash", {
273
+ command: "env assistant config",
274
+ });
275
+ expectLowRisk("env assistant config", risk);
276
+ });
277
+
278
+ test("env git push is Medium risk (not Low)", async () => {
279
+ const risk = await classifyRisk("bash", { command: "env git push" });
280
+ expect(risk).toBe(RiskLevel.Medium);
281
+ });
282
+
283
+ test("env git status is Low risk", async () => {
284
+ const risk = await classifyRisk("bash", { command: "env git status" });
285
+ expectLowRisk("env git status", risk);
286
+ });
287
+ });
@@ -0,0 +1,396 @@
1
+ /**
2
+ * Tests that deleting or wiping a conversation with an associated schedule
3
+ * job also deletes the schedule, preventing orphaned scheduled automations.
4
+ */
5
+
6
+ import { mkdtempSync, rmSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
10
+
11
+ const testDir = mkdtempSync(
12
+ join(tmpdir(), "conv-delete-schedule-cleanup-test-"),
13
+ );
14
+
15
+ mock.module("../util/platform.js", () => ({
16
+ getRootDir: () => testDir,
17
+ getDataDir: () => testDir,
18
+ isMacOS: () => process.platform === "darwin",
19
+ isLinux: () => process.platform === "linux",
20
+ isWindows: () => process.platform === "win32",
21
+ getPidPath: () => join(testDir, "test.pid"),
22
+ getDbPath: () => join(testDir, "test.db"),
23
+ getLogPath: () => join(testDir, "test.log"),
24
+ ensureDataDir: () => {},
25
+ }));
26
+
27
+ mock.module("../util/logger.js", () => ({
28
+ getLogger: () =>
29
+ new Proxy({} as Record<string, unknown>, {
30
+ get: () => () => {},
31
+ }),
32
+ }));
33
+
34
+ mock.module("../config/env.js", () => ({
35
+ isHttpAuthDisabled: () => true,
36
+ hasUngatedHttpAuthDisabled: () => false,
37
+ }));
38
+
39
+ import type { Database } from "bun:sqlite";
40
+
41
+ import {
42
+ createConversation,
43
+ getConversation,
44
+ } from "../memory/conversation-crud.js";
45
+ import { getDb, initializeDb, resetDb } from "../memory/db.js";
46
+ import { conversationManagementRouteDefinitions } from "../runtime/routes/conversation-management-routes.js";
47
+ import { createSchedule, getSchedule } from "../schedule/schedule-store.js";
48
+
49
+ initializeDb();
50
+
51
+ afterAll(() => {
52
+ resetDb();
53
+ try {
54
+ rmSync(testDir, { recursive: true });
55
+ } catch {
56
+ /* best effort */
57
+ }
58
+ });
59
+
60
+ function getRawDb(): Database {
61
+ return (getDb() as unknown as { $client: Database }).$client;
62
+ }
63
+
64
+ /** Build route definitions with minimal deps. */
65
+ function getRoutes() {
66
+ const routes = conversationManagementRouteDefinitions({
67
+ switchConversation: async () => null,
68
+ renameConversation: () => true,
69
+ clearAllConversations: () => 0,
70
+ cancelGeneration: () => true,
71
+ destroyConversation: () => {},
72
+ undoLastMessage: async () => null,
73
+ regenerateResponse: async () => null,
74
+ });
75
+ return routes;
76
+ }
77
+
78
+ function getDeleteHandler() {
79
+ const deleteRoute = getRoutes().find(
80
+ (r) => r.endpoint === "conversations/:id" && r.method === "DELETE",
81
+ );
82
+ if (!deleteRoute) throw new Error("DELETE conversations/:id route not found");
83
+ return deleteRoute.handler;
84
+ }
85
+
86
+ function getWipeHandler() {
87
+ const wipeRoute = getRoutes().find(
88
+ (r) => r.endpoint === "conversations/:id/wipe" && r.method === "POST",
89
+ );
90
+ if (!wipeRoute)
91
+ throw new Error("POST conversations/:id/wipe route not found");
92
+ return wipeRoute.handler;
93
+ }
94
+
95
+ describe("DELETE /conversations/:id — schedule cleanup", () => {
96
+ beforeEach(() => {
97
+ getRawDb().run("DELETE FROM cron_runs");
98
+ getRawDb().run("DELETE FROM cron_jobs");
99
+ getRawDb().run("DELETE FROM memory_item_sources");
100
+ getRawDb().run("DELETE FROM memory_segments");
101
+ getRawDb().run("DELETE FROM memory_items");
102
+ getRawDb().run("DELETE FROM memory_summaries");
103
+ getRawDb().run("DELETE FROM memory_embeddings");
104
+ getRawDb().run("DELETE FROM memory_jobs");
105
+ getRawDb().run("DELETE FROM tool_invocations");
106
+ getRawDb().run("DELETE FROM llm_request_logs");
107
+ getRawDb().run("DELETE FROM messages");
108
+ getRawDb().run("DELETE FROM conversations");
109
+ });
110
+
111
+ test("deleting a conversation with a scheduleJobId removes the schedule", async () => {
112
+ // Create a schedule job
113
+ const schedule = createSchedule({
114
+ name: "Daily standup",
115
+ expression: "0 9 * * 1-5",
116
+ message: "Time for standup!",
117
+ });
118
+
119
+ // Create a conversation linked to that schedule
120
+ const conv = createConversation({
121
+ source: "schedule",
122
+ scheduleJobId: schedule.id,
123
+ });
124
+
125
+ // Verify the schedule exists
126
+ expect(getSchedule(schedule.id)).not.toBeNull();
127
+
128
+ // Call the DELETE handler
129
+ const handler = getDeleteHandler();
130
+ const req = new Request(`http://localhost/v1/conversations/${conv.id}`, {
131
+ method: "DELETE",
132
+ });
133
+ const response = await handler({
134
+ req,
135
+ url: new URL(req.url),
136
+ server: {} as never,
137
+ authContext: undefined as never,
138
+ params: { id: conv.id },
139
+ });
140
+
141
+ expect(response.status).toBe(204);
142
+
143
+ // Schedule should be deleted
144
+ expect(getSchedule(schedule.id)).toBeNull();
145
+
146
+ // Conversation should be deleted
147
+ expect(getConversation(conv.id)).toBeNull();
148
+ });
149
+
150
+ test("deleting a conversation without a scheduleJobId does not affect schedules", async () => {
151
+ // Create a schedule job (not linked to any conversation)
152
+ const schedule = createSchedule({
153
+ name: "Unrelated schedule",
154
+ expression: "0 12 * * *",
155
+ message: "Noon check",
156
+ });
157
+
158
+ // Create a conversation with no schedule link
159
+ const conv = createConversation("no-schedule-conv");
160
+
161
+ // Call the DELETE handler
162
+ const handler = getDeleteHandler();
163
+ const req = new Request(`http://localhost/v1/conversations/${conv.id}`, {
164
+ method: "DELETE",
165
+ });
166
+ const response = await handler({
167
+ req,
168
+ url: new URL(req.url),
169
+ server: {} as never,
170
+ authContext: undefined as never,
171
+ params: { id: conv.id },
172
+ });
173
+
174
+ expect(response.status).toBe(204);
175
+
176
+ // Unrelated schedule should still exist
177
+ expect(getSchedule(schedule.id)).not.toBeNull();
178
+
179
+ // Conversation should be deleted
180
+ expect(getConversation(conv.id)).toBeNull();
181
+ });
182
+
183
+ test("deleting a conversation with a schedule also removes its cron_runs", async () => {
184
+ // Create a schedule job
185
+ const schedule = createSchedule({
186
+ name: "Recurring job",
187
+ expression: "0 9 * * *",
188
+ message: "Daily task",
189
+ });
190
+
191
+ // Create a conversation linked to the schedule
192
+ const conv = createConversation({
193
+ source: "schedule",
194
+ scheduleJobId: schedule.id,
195
+ });
196
+
197
+ // Insert a cron_run record for this schedule
198
+ const now = Date.now();
199
+ getRawDb()
200
+ .query(
201
+ `INSERT INTO cron_runs (id, job_id, conversation_id, status, started_at, created_at)
202
+ VALUES ('run-1', ?, ?, 'ok', ?, ?)`,
203
+ )
204
+ .run(schedule.id, conv.id, now, now);
205
+
206
+ // Verify the run exists
207
+ const runBefore = getRawDb()
208
+ .query("SELECT * FROM cron_runs WHERE id = 'run-1'")
209
+ .get();
210
+ expect(runBefore).not.toBeNull();
211
+
212
+ // Call the DELETE handler
213
+ const handler = getDeleteHandler();
214
+ const req = new Request(`http://localhost/v1/conversations/${conv.id}`, {
215
+ method: "DELETE",
216
+ });
217
+ const response = await handler({
218
+ req,
219
+ url: new URL(req.url),
220
+ server: {} as never,
221
+ authContext: undefined as never,
222
+ params: { id: conv.id },
223
+ });
224
+
225
+ expect(response.status).toBe(204);
226
+
227
+ // Schedule and its runs should be deleted (FK cascade)
228
+ expect(getSchedule(schedule.id)).toBeNull();
229
+ const runAfter = getRawDb()
230
+ .query("SELECT * FROM cron_runs WHERE id = 'run-1'")
231
+ .get();
232
+ expect(runAfter).toBeNull();
233
+ });
234
+
235
+ test("deleting one of multiple conversations sharing a schedule preserves the schedule", async () => {
236
+ // Recurring schedules create a new conversation per run, all sharing
237
+ // the same scheduleJobId. Deleting an earlier run conversation must
238
+ // NOT cancel the schedule while other conversations still reference it.
239
+ const schedule = createSchedule({
240
+ name: "Recurring daily",
241
+ expression: "0 9 * * *",
242
+ message: "Daily task",
243
+ });
244
+
245
+ // Two conversations referencing the same schedule (simulates two runs)
246
+ const conv1 = createConversation({
247
+ source: "schedule",
248
+ scheduleJobId: schedule.id,
249
+ });
250
+ createConversation({
251
+ source: "schedule",
252
+ scheduleJobId: schedule.id,
253
+ });
254
+
255
+ // Delete the first conversation
256
+ const handler = getDeleteHandler();
257
+ const req = new Request(`http://localhost/v1/conversations/${conv1.id}`, {
258
+ method: "DELETE",
259
+ });
260
+ const response = await handler({
261
+ req,
262
+ url: new URL(req.url),
263
+ server: {} as never,
264
+ authContext: undefined as never,
265
+ params: { id: conv1.id },
266
+ });
267
+
268
+ expect(response.status).toBe(204);
269
+
270
+ // Schedule should still exist because another conversation references it
271
+ expect(getSchedule(schedule.id)).not.toBeNull();
272
+ });
273
+
274
+ test("deleting one scheduled conversation does not affect other schedules", async () => {
275
+ // Create two separate schedules
276
+ const scheduleA = createSchedule({
277
+ name: "Schedule A",
278
+ expression: "0 9 * * *",
279
+ message: "Task A",
280
+ });
281
+ const scheduleB = createSchedule({
282
+ name: "Schedule B",
283
+ expression: "0 17 * * *",
284
+ message: "Task B",
285
+ });
286
+
287
+ // Create conversations linked to each schedule
288
+ const convA = createConversation({
289
+ source: "schedule",
290
+ scheduleJobId: scheduleA.id,
291
+ });
292
+ createConversation({
293
+ source: "schedule",
294
+ scheduleJobId: scheduleB.id,
295
+ });
296
+
297
+ // Delete only conversation A
298
+ const handler = getDeleteHandler();
299
+ const req = new Request(`http://localhost/v1/conversations/${convA.id}`, {
300
+ method: "DELETE",
301
+ });
302
+ const response = await handler({
303
+ req,
304
+ url: new URL(req.url),
305
+ server: {} as never,
306
+ authContext: undefined as never,
307
+ params: { id: convA.id },
308
+ });
309
+
310
+ expect(response.status).toBe(204);
311
+
312
+ // Schedule A should be deleted
313
+ expect(getSchedule(scheduleA.id)).toBeNull();
314
+
315
+ // Schedule B should still exist
316
+ expect(getSchedule(scheduleB.id)).not.toBeNull();
317
+ });
318
+ });
319
+
320
+ describe("POST /conversations/:id/wipe — schedule cleanup", () => {
321
+ beforeEach(() => {
322
+ getRawDb().run("DELETE FROM cron_runs");
323
+ getRawDb().run("DELETE FROM cron_jobs");
324
+ getRawDb().run("DELETE FROM memory_item_sources");
325
+ getRawDb().run("DELETE FROM memory_segments");
326
+ getRawDb().run("DELETE FROM memory_items");
327
+ getRawDb().run("DELETE FROM memory_summaries");
328
+ getRawDb().run("DELETE FROM memory_embeddings");
329
+ getRawDb().run("DELETE FROM memory_jobs");
330
+ getRawDb().run("DELETE FROM tool_invocations");
331
+ getRawDb().run("DELETE FROM llm_request_logs");
332
+ getRawDb().run("DELETE FROM messages");
333
+ getRawDb().run("DELETE FROM conversations");
334
+ });
335
+
336
+ test("wiping a conversation with a scheduleJobId removes the schedule", async () => {
337
+ const schedule = createSchedule({
338
+ name: "Wipe-test schedule",
339
+ expression: "0 9 * * 1-5",
340
+ message: "Time for standup!",
341
+ });
342
+
343
+ const conv = createConversation({
344
+ source: "schedule",
345
+ scheduleJobId: schedule.id,
346
+ });
347
+
348
+ expect(getSchedule(schedule.id)).not.toBeNull();
349
+
350
+ const handler = getWipeHandler();
351
+ const req = new Request(
352
+ `http://localhost/v1/conversations/${conv.id}/wipe`,
353
+ { method: "POST" },
354
+ );
355
+ const response = await handler({
356
+ req,
357
+ url: new URL(req.url),
358
+ server: {} as never,
359
+ authContext: undefined as never,
360
+ params: { id: conv.id },
361
+ });
362
+
363
+ expect(response.status).toBe(200);
364
+
365
+ // Schedule should be deleted
366
+ expect(getSchedule(schedule.id)).toBeNull();
367
+ });
368
+
369
+ test("wiping a conversation without a scheduleJobId does not affect schedules", async () => {
370
+ const schedule = createSchedule({
371
+ name: "Unrelated schedule",
372
+ expression: "0 12 * * *",
373
+ message: "Noon check",
374
+ });
375
+
376
+ const conv = createConversation("no-schedule-wipe");
377
+
378
+ const handler = getWipeHandler();
379
+ const req = new Request(
380
+ `http://localhost/v1/conversations/${conv.id}/wipe`,
381
+ { method: "POST" },
382
+ );
383
+ const response = await handler({
384
+ req,
385
+ url: new URL(req.url),
386
+ server: {} as never,
387
+ authContext: undefined as never,
388
+ params: { id: conv.id },
389
+ });
390
+
391
+ expect(response.status).toBe(200);
392
+
393
+ // Unrelated schedule should still exist
394
+ expect(getSchedule(schedule.id)).not.toBeNull();
395
+ });
396
+ });
@@ -435,11 +435,12 @@ describe("classifyConversationError", () => {
435
435
  expect(result.retryable).toBe(true);
436
436
  });
437
437
 
438
- it("classifies ProviderError with 401 as PROVIDER_BILLING (non-retryable)", () => {
438
+ it("classifies ProviderError with 401 as PROVIDER_NOT_CONFIGURED (non-retryable)", () => {
439
439
  const err = new ProviderError("Unauthorized", "anthropic", 401);
440
440
  const result = classifyConversationError(err, baseCtx);
441
- expect(result.code).toBe("PROVIDER_BILLING");
441
+ expect(result.code).toBe("PROVIDER_NOT_CONFIGURED");
442
442
  expect(result.retryable).toBe(false);
443
+ expect(result.errorCategory).toBe("provider_not_configured");
443
444
  });
444
445
 
445
446
  it("classifies ProviderError with 402 as credits_exhausted (non-retryable)", () => {
@@ -483,13 +483,9 @@ describe("Invariant 6: oauth2ClientSecret not in metadata, only in secure store"
483
483
  });
484
484
 
485
485
  test("upsertCredentialMetadata does not accept oauth2ClientSecret or other OAuth fields", () => {
486
- const record = upsertCredentialMetadata(
487
- "integration:google",
488
- "access_token",
489
- {
490
- allowedTools: ["api_request"],
491
- },
492
- );
486
+ const record = upsertCredentialMetadata("google", "access_token", {
487
+ allowedTools: ["api_request"],
488
+ });
493
489
  expect("oauth2ClientSecret" in record).toBe(false);
494
490
  expect("oauth2TokenUrl" in record).toBe(false);
495
491
  expect("oauth2ClientId" in record).toBe(false);
@@ -497,14 +493,14 @@ describe("Invariant 6: oauth2ClientSecret not in metadata, only in secure store"
497
493
 
498
494
  test("client secret is read from secure store, not metadata", async () => {
499
495
  await setSecureKeyAsync(
500
- credentialKey("integration:google", "client_secret"),
496
+ credentialKey("google", "client_secret"),
501
497
  "my-secret",
502
498
  );
503
- upsertCredentialMetadata("integration:google", "access_token", {
499
+ upsertCredentialMetadata("google", "access_token", {
504
500
  allowedTools: ["api_request"],
505
501
  });
506
502
 
507
- const meta = getCredentialMetadata("integration:google", "access_token");
503
+ const meta = getCredentialMetadata("google", "access_token");
508
504
  expect(meta).toBeDefined();
509
505
  expect("oauth2ClientSecret" in meta!).toBe(false);
510
506
  // OAuth-specific fields are no longer in metadata (v5)
@@ -513,9 +509,7 @@ describe("Invariant 6: oauth2ClientSecret not in metadata, only in secure store"
513
509
 
514
510
  // Secret is in secure store
515
511
  expect(
516
- await getSecureKeyAsync(
517
- credentialKey("integration:google", "client_secret"),
518
- ),
512
+ await getSecureKeyAsync(credentialKey("google", "client_secret")),
519
513
  ).toBe("my-secret");
520
514
  });
521
515
 
@@ -525,7 +519,7 @@ describe("Invariant 6: oauth2ClientSecret not in metadata, only in secure store"
525
519
  credentials: [
526
520
  {
527
521
  credentialId: "cred-v2-secret",
528
- service: "integration:google",
522
+ service: "google",
529
523
  field: "access_token",
530
524
  allowedTools: [],
531
525
  allowedDomains: [],
@@ -543,7 +537,7 @@ describe("Invariant 6: oauth2ClientSecret not in metadata, only in secure store"
543
537
  "utf-8",
544
538
  );
545
539
 
546
- const meta = getCredentialMetadata("integration:google", "access_token");
540
+ const meta = getCredentialMetadata("google", "access_token");
547
541
  expect(meta).toBeDefined();
548
542
  expect("oauth2ClientSecret" in meta!).toBe(false);
549
543