@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.
- package/Dockerfile +1 -0
- package/docs/architecture/integrations.md +34 -32
- package/node_modules/@vellumai/ces-contracts/src/__tests__/grants.test.ts +7 -7
- package/node_modules/@vellumai/ces-contracts/src/handles.ts +5 -4
- package/node_modules/@vellumai/credential-storage/src/index.ts +1 -1
- package/openapi.yaml +87 -9
- package/package.json +1 -1
- package/src/__tests__/catalog-cache.test.ts +164 -0
- package/src/__tests__/catalog-search.test.ts +61 -0
- package/src/__tests__/cli-command-risk-guard.test.ts +181 -6
- package/src/__tests__/conversation-delete-schedule-cleanup.test.ts +396 -0
- package/src/__tests__/conversation-error.test.ts +3 -2
- package/src/__tests__/credential-security-invariants.test.ts +9 -15
- package/src/__tests__/credential-vault-unit.test.ts +32 -34
- package/src/__tests__/credential-vault.test.ts +25 -33
- package/src/__tests__/credentials-cli.test.ts +3 -3
- package/src/__tests__/daemon-credential-client.test.ts +2 -2
- package/src/__tests__/host-bash-proxy.test.ts +79 -0
- package/src/__tests__/host-cu-proxy.test.ts +90 -0
- package/src/__tests__/host-file-proxy.test.ts +89 -0
- package/src/__tests__/integration-status.test.ts +5 -5
- package/src/__tests__/list-messages-attachments.test.ts +171 -0
- package/src/__tests__/mcp-abort-signal.test.ts +205 -0
- package/src/__tests__/messaging-send-tool.test.ts +5 -5
- package/src/__tests__/notification-telegram-adapter.test.ts +125 -0
- package/src/__tests__/oauth-cli.test.ts +126 -119
- package/src/__tests__/oauth-provider-profiles.test.ts +55 -20
- package/src/__tests__/oauth-scope-policy.test.ts +4 -6
- package/src/__tests__/onboarding-template-contract.test.ts +2 -2
- package/src/__tests__/secret-routes-managed-proxy.test.ts +78 -0
- package/src/__tests__/secure-keys-managed-failover.test.ts +73 -0
- package/src/__tests__/skills-uninstall.test.ts +2 -2
- package/src/__tests__/slack-messaging-token-resolution.test.ts +22 -24
- package/src/__tests__/slack-share-routes.test.ts +5 -5
- package/src/__tests__/system-prompt.test.ts +39 -0
- package/src/__tests__/workspace-migration-backfill-installation-id.test.ts +5 -4
- package/src/cli/AGENTS.md +47 -7
- package/src/cli/commands/browser-relay.ts +2 -17
- package/src/cli/commands/contacts.ts +6 -4
- package/src/cli/commands/conversations.ts +13 -1
- package/src/cli/commands/credential-execution.ts +16 -1
- package/src/cli/commands/credentials.ts +2 -8
- package/src/cli/commands/oauth/__tests__/connect.test.ts +29 -108
- package/src/cli/commands/oauth/__tests__/disconnect.test.ts +13 -87
- package/src/cli/commands/oauth/__tests__/mode.test.ts +22 -69
- package/src/cli/commands/oauth/__tests__/ping.test.ts +20 -79
- package/src/cli/commands/oauth/__tests__/providers-delete.test.ts +574 -0
- package/src/cli/commands/oauth/__tests__/providers-update.test.ts +416 -0
- package/src/cli/commands/oauth/__tests__/status.test.ts +12 -40
- package/src/cli/commands/oauth/__tests__/token.test.ts +3 -50
- package/src/cli/commands/oauth/apps.ts +63 -44
- package/src/cli/commands/oauth/connect.ts +187 -155
- package/src/cli/commands/oauth/disconnect.ts +27 -75
- package/src/cli/commands/oauth/index.ts +36 -46
- package/src/cli/commands/oauth/mode.ts +22 -34
- package/src/cli/commands/oauth/ping.ts +19 -45
- package/src/cli/commands/oauth/providers.ts +569 -62
- package/src/cli/commands/oauth/request.ts +36 -48
- package/src/cli/commands/oauth/shared.ts +1 -19
- package/src/cli/commands/oauth/status.ts +14 -25
- package/src/cli/commands/oauth/token.ts +25 -34
- package/src/cli/commands/platform/connect.ts +104 -0
- package/src/cli/commands/platform/disconnect.ts +118 -0
- package/src/cli/commands/{platform.ts → platform/index.ts} +108 -38
- package/src/cli/commands/sequence.ts +5 -4
- package/src/cli/commands/shotgun.ts +16 -0
- package/src/cli/commands/skills.ts +173 -41
- package/src/cli/commands/usage.ts +5 -11
- package/src/cli/lib/daemon-credential-client.ts +22 -38
- package/src/cli/program.ts +1 -1
- package/src/config/assistant-feature-flags.ts +3 -7
- package/src/config/bundled-skills/contacts/tools/google-contacts.ts +1 -1
- package/src/config/bundled-skills/conversations/SKILL.md +20 -0
- package/src/config/bundled-skills/conversations/TOOLS.json +23 -0
- package/src/config/bundled-skills/conversations/tools/rename-conversation.ts +66 -0
- package/src/config/bundled-skills/gmail/SKILL.md +13 -13
- package/src/config/bundled-skills/gmail/tools/gmail-archive.ts +3 -3
- package/src/config/bundled-skills/gmail/tools/gmail-attachments.ts +2 -2
- package/src/config/bundled-skills/gmail/tools/gmail-draft.ts +1 -1
- package/src/config/bundled-skills/gmail/tools/gmail-filters.ts +1 -1
- package/src/config/bundled-skills/gmail/tools/gmail-follow-up.ts +1 -1
- package/src/config/bundled-skills/gmail/tools/gmail-forward.ts +1 -1
- package/src/config/bundled-skills/gmail/tools/gmail-label.ts +2 -2
- package/src/config/bundled-skills/gmail/tools/gmail-outreach-scan.ts +1 -1
- package/src/config/bundled-skills/gmail/tools/gmail-send-draft.ts +1 -1
- package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +1 -1
- package/src/config/bundled-skills/gmail/tools/gmail-trash.ts +1 -1
- package/src/config/bundled-skills/gmail/tools/gmail-unsubscribe.ts +1 -1
- package/src/config/bundled-skills/gmail/tools/gmail-vacation.ts +1 -1
- package/src/config/bundled-skills/google-calendar/SKILL.md +10 -4
- package/src/config/bundled-skills/google-calendar/tools/shared.ts +1 -1
- package/src/config/bundled-skills/messaging/SKILL.md +7 -7
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -2
- package/src/config/bundled-skills/messaging/tools/shared.ts +5 -6
- package/src/config/bundled-tool-registry.ts +5 -0
- package/src/config/feature-flag-registry.json +1 -1
- package/src/credential-execution/client.ts +1 -1
- package/src/daemon/conversation-agent-loop.ts +2 -0
- package/src/daemon/conversation-error.ts +36 -6
- package/src/daemon/conversation-messaging.ts +9 -0
- package/src/daemon/conversation-runtime-assembly.ts +33 -0
- package/src/daemon/conversation-surfaces.ts +120 -14
- package/src/daemon/conversation.ts +5 -0
- package/src/daemon/handlers/skills.ts +148 -3
- package/src/daemon/host-bash-proxy.ts +16 -0
- package/src/daemon/host-cu-proxy.ts +16 -0
- package/src/daemon/host-file-proxy.ts +16 -0
- package/src/daemon/lifecycle.ts +47 -1
- package/src/daemon/message-types/conversations.ts +1 -0
- package/src/daemon/message-types/guardian-actions.ts +2 -0
- package/src/daemon/message-types/host-bash.ts +6 -1
- package/src/daemon/message-types/host-cu.ts +6 -1
- package/src/daemon/message-types/host-file.ts +6 -1
- package/src/daemon/message-types/integrations.ts +0 -1
- package/src/daemon/server.ts +29 -2
- package/src/hooks/cli.ts +74 -0
- package/src/inbound/platform-callback-registration.ts +7 -12
- package/src/mcp/client.ts +6 -1
- package/src/mcp/manager.ts +2 -1
- package/src/memory/conversation-crud.ts +92 -3
- package/src/memory/conversation-key-store.ts +26 -0
- package/src/memory/db-init.ts +16 -0
- package/src/memory/migrations/196-messages-conversation-created-at-index.ts +9 -0
- package/src/memory/migrations/196-strip-integration-prefix-from-provider-keys.ts +186 -0
- package/src/memory/migrations/197-oauth-providers-behavior-columns.ts +29 -0
- package/src/memory/migrations/198-drop-setup-skill-id-column.ts +11 -0
- package/src/memory/migrations/index.ts +4 -0
- package/src/memory/migrations/registry.ts +8 -0
- package/src/memory/schema/oauth.ts +11 -0
- package/src/messaging/provider.ts +13 -12
- package/src/messaging/providers/gmail/adapter.ts +44 -35
- package/src/messaging/providers/slack/adapter.ts +63 -33
- package/src/messaging/providers/telegram-bot/adapter.ts +6 -8
- package/src/messaging/providers/whatsapp/adapter.ts +6 -8
- package/src/notifications/adapters/telegram.ts +78 -2
- package/src/oauth/__tests__/identity-verifier.test.ts +464 -0
- package/src/oauth/byo-connection.test.ts +22 -24
- package/src/oauth/connect-orchestrator.ts +37 -76
- package/src/oauth/connect-types.ts +7 -65
- package/src/oauth/connection-resolver.test.ts +13 -13
- package/src/oauth/connection-resolver.ts +3 -4
- package/src/oauth/identity-verifier.ts +177 -0
- package/src/oauth/oauth-store.ts +228 -3
- package/src/oauth/platform-connection.test.ts +56 -6
- package/src/oauth/platform-connection.ts +8 -1
- package/src/oauth/seed-providers.ts +247 -34
- package/src/permissions/checker.ts +127 -1
- package/src/prompts/system-prompt.ts +43 -9
- package/src/prompts/templates/BOOTSTRAP.md +16 -5
- package/src/providers/anthropic/client.ts +2 -33
- package/src/runtime/guardian-action-service.ts +7 -2
- package/src/runtime/http-server.ts +5 -3
- package/src/runtime/http-types.ts +8 -1
- package/src/runtime/routes/conversation-management-routes.ts +31 -0
- package/src/runtime/routes/conversation-routes.ts +79 -4
- package/src/runtime/routes/guardian-action-routes.ts +15 -2
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +21 -8
- package/src/runtime/routes/integrations/slack/share.ts +1 -1
- package/src/runtime/routes/oauth-apps.ts +2 -1
- package/src/runtime/routes/secret-routes.ts +36 -13
- package/src/runtime/routes/settings-routes.ts +12 -19
- package/src/runtime/routes/skills-routes.ts +45 -4
- package/src/schedule/integration-status.ts +2 -2
- package/src/security/ces-rpc-credential-backend.ts +19 -16
- package/src/security/oauth-completion-page.ts +153 -0
- package/src/security/oauth2.ts +3 -17
- package/src/security/secure-keys.ts +207 -7
- package/src/security/token-manager.ts +3 -6
- package/src/signals/bash.ts +6 -1
- package/src/skills/catalog-cache.ts +44 -0
- package/src/skills/catalog-search.ts +18 -0
- package/src/tools/credentials/post-connect-hooks.ts +1 -1
- package/src/tools/credentials/vault.ts +34 -45
- package/src/tools/host-terminal/host-shell.ts +16 -3
- package/src/tools/mcp/mcp-tool-factory.ts +2 -1
- package/src/tools/skills/sandbox-runner.ts +16 -3
- package/src/tools/terminal/shell.ts +16 -3
- package/src/util/logger.ts +11 -1
- package/src/util/sentry-log-stream.ts +51 -0
- package/src/watcher/providers/github.ts +2 -2
- package/src/watcher/providers/gmail.ts +1 -1
- package/src/watcher/providers/google-calendar.ts +1 -1
- package/src/watcher/providers/linear.ts +2 -2
- package/src/workspace/migrations/011-backfill-installation-id.ts +5 -3
- package/src/workspace/migrations/020-rename-oauth-skill-dirs.ts +119 -0
- package/src/workspace/migrations/registry.ts +2 -0
- package/src/cli/commands/oauth/connections.ts +0 -255
- package/src/oauth/provider-behaviors.ts +0 -634
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
// Guard test: assistant CLI commands must
|
|
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.
|
|
4
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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("
|
|
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
|
-
|
|
488
|
-
|
|
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("
|
|
496
|
+
credentialKey("google", "client_secret"),
|
|
501
497
|
"my-secret",
|
|
502
498
|
);
|
|
503
|
-
upsertCredentialMetadata("
|
|
499
|
+
upsertCredentialMetadata("google", "access_token", {
|
|
504
500
|
allowedTools: ["api_request"],
|
|
505
501
|
});
|
|
506
502
|
|
|
507
|
-
const meta = getCredentialMetadata("
|
|
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: "
|
|
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("
|
|
540
|
+
const meta = getCredentialMetadata("google", "access_token");
|
|
547
541
|
expect(meta).toBeDefined();
|
|
548
542
|
expect("oauth2ClientSecret" in meta!).toBe(false);
|
|
549
543
|
|