@vellumai/assistant 0.5.11 → 0.5.13
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 +42 -9
- 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/ces-contracts/src/index.ts +7 -0
- package/node_modules/@vellumai/ces-contracts/src/rpc.ts +5 -0
- 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__/first-greeting.test.ts +7 -0
- 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__/navigate-settings-tab.test.ts +6 -2
- 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__/platform.test.ts +3 -168
- 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__/skill-feature-flags.test.ts +8 -0
- package/src/__tests__/skill-secret-handling-guard.test.ts +212 -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__/token-estimator-accuracy.benchmark.test.ts +1 -1
- 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/__tests__/connect.test.ts +224 -0
- package/src/cli/commands/platform/__tests__/disconnect.test.ts +237 -0
- package/src/cli/commands/platform/__tests__/status.test.ts +246 -0
- 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-skills/settings/TOOLS.json +5 -3
- package/src/config/bundled-skills/settings/tools/navigate-settings-tab.ts +4 -2
- package/src/config/bundled-tool-registry.ts +5 -0
- package/src/config/feature-flag-registry.json +2 -2
- package/src/credential-execution/client.ts +15 -3
- 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/first-greeting.ts +6 -1
- 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 +56 -5
- 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/index.ts +0 -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/conversation-queries.ts +6 -6
- package/src/memory/db-init.ts +16 -0
- package/src/memory/journal-memory.ts +8 -2
- 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/journal-context.ts +4 -1
- package/src/prompts/system-prompt.ts +54 -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 +12 -18
- package/src/runtime/http-types.ts +8 -1
- package/src/runtime/migrations/rebind-secrets-screen.ts +2 -2
- 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 +45 -15
- 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/browser/browser-manager.ts +2 -2
- 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/platform.ts +1 -91
- 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
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { filterByQuery } from "../skills/catalog-search.js";
|
|
4
|
+
|
|
5
|
+
interface FakeSkill {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
description: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const skills: FakeSkill[] = [
|
|
12
|
+
{
|
|
13
|
+
id: "weather",
|
|
14
|
+
name: "Weather Lookup",
|
|
15
|
+
description: "Get current weather for a city",
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: "search",
|
|
19
|
+
name: "Web Search",
|
|
20
|
+
description: "Search the web for information",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: "deploy",
|
|
24
|
+
name: "Deploy Helper",
|
|
25
|
+
description: "Deploy apps to production",
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const fields: ((s: FakeSkill) => string)[] = [
|
|
30
|
+
(s) => s.id,
|
|
31
|
+
(s) => s.name,
|
|
32
|
+
(s) => s.description,
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
describe("filterByQuery", () => {
|
|
36
|
+
test("case-insensitive matching", () => {
|
|
37
|
+
const result = filterByQuery(skills, "WEATHER", fields);
|
|
38
|
+
expect(result).toEqual([skills[0]]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("matches on any supplied field accessor", () => {
|
|
42
|
+
// Match on id
|
|
43
|
+
expect(filterByQuery(skills, "deploy", fields)).toEqual([skills[2]]);
|
|
44
|
+
// Match on name
|
|
45
|
+
expect(filterByQuery(skills, "Web Search", fields)).toEqual([skills[1]]);
|
|
46
|
+
// Match on description
|
|
47
|
+
expect(filterByQuery(skills, "production", fields)).toEqual([skills[2]]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("returns empty array for no matches", () => {
|
|
51
|
+
const result = filterByQuery(skills, "nonexistent", fields);
|
|
52
|
+
expect(result).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("returns all items for broad query", () => {
|
|
56
|
+
// All skills have "e" somewhere in their fields
|
|
57
|
+
const result = filterByQuery(skills, "e", fields);
|
|
58
|
+
expect(result).toHaveLength(3);
|
|
59
|
+
expect(result).toEqual(skills);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -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)", () => {
|