vellum 0.2.13 → 0.2.14

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 (207) hide show
  1. package/README.md +32 -0
  2. package/bun.lock +2 -2
  3. package/docs/skills.md +4 -4
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
  6. package/src/__tests__/app-git-history.test.ts +176 -0
  7. package/src/__tests__/app-git-service.test.ts +169 -0
  8. package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
  9. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
  10. package/src/__tests__/browser-skill-endstate.test.ts +6 -6
  11. package/src/__tests__/call-bridge.test.ts +105 -13
  12. package/src/__tests__/call-domain.test.ts +163 -0
  13. package/src/__tests__/call-orchestrator.test.ts +113 -0
  14. package/src/__tests__/call-routes-http.test.ts +246 -6
  15. package/src/__tests__/channel-approval-routes.test.ts +438 -0
  16. package/src/__tests__/channel-approval.test.ts +266 -0
  17. package/src/__tests__/channel-approvals.test.ts +393 -0
  18. package/src/__tests__/channel-delivery-store.test.ts +447 -0
  19. package/src/__tests__/checker.test.ts +607 -1048
  20. package/src/__tests__/cli.test.ts +1 -56
  21. package/src/__tests__/config-schema.test.ts +137 -18
  22. package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
  23. package/src/__tests__/conflict-policy.test.ts +121 -0
  24. package/src/__tests__/conflict-store.test.ts +2 -0
  25. package/src/__tests__/contacts-tools.test.ts +3 -3
  26. package/src/__tests__/contradiction-checker.test.ts +99 -1
  27. package/src/__tests__/credential-security-invariants.test.ts +22 -6
  28. package/src/__tests__/credential-vault-unit.test.ts +780 -0
  29. package/src/__tests__/elevenlabs-client.test.ts +62 -0
  30. package/src/__tests__/ephemeral-permissions.test.ts +73 -23
  31. package/src/__tests__/filesystem-tools.test.ts +579 -0
  32. package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
  33. package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
  34. package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
  35. package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
  36. package/src/__tests__/handlers-slack-config.test.ts +2 -1
  37. package/src/__tests__/handlers-telegram-config.test.ts +855 -0
  38. package/src/__tests__/handlers-twitter-config.test.ts +141 -1
  39. package/src/__tests__/hooks-runner.test.ts +6 -2
  40. package/src/__tests__/host-file-edit-tool.test.ts +124 -0
  41. package/src/__tests__/host-file-read-tool.test.ts +62 -0
  42. package/src/__tests__/host-file-write-tool.test.ts +59 -0
  43. package/src/__tests__/host-shell-tool.test.ts +251 -0
  44. package/src/__tests__/ingress-reconcile.test.ts +581 -0
  45. package/src/__tests__/ipc-snapshot.test.ts +100 -41
  46. package/src/__tests__/ipc-validate.test.ts +50 -0
  47. package/src/__tests__/key-migration.test.ts +23 -0
  48. package/src/__tests__/memory-regressions.test.ts +99 -0
  49. package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
  50. package/src/__tests__/oauth-callback-registry.test.ts +11 -4
  51. package/src/__tests__/playbook-execution.test.ts +502 -0
  52. package/src/__tests__/playbook-tools.test.ts +4 -6
  53. package/src/__tests__/public-ingress-urls.test.ts +34 -0
  54. package/src/__tests__/qdrant-manager.test.ts +267 -0
  55. package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
  56. package/src/__tests__/recurrence-engine.test.ts +9 -0
  57. package/src/__tests__/recurrence-types.test.ts +8 -0
  58. package/src/__tests__/registry.test.ts +1 -1
  59. package/src/__tests__/runtime-runs.test.ts +1 -25
  60. package/src/__tests__/schedule-store.test.ts +16 -14
  61. package/src/__tests__/schedule-tools.test.ts +83 -0
  62. package/src/__tests__/scheduler-recurrence.test.ts +111 -10
  63. package/src/__tests__/secret-allowlist.test.ts +18 -17
  64. package/src/__tests__/secret-ingress-handler.test.ts +11 -0
  65. package/src/__tests__/secret-scanner.test.ts +43 -0
  66. package/src/__tests__/session-conflict-gate.test.ts +442 -6
  67. package/src/__tests__/session-init.benchmark.test.ts +3 -0
  68. package/src/__tests__/session-process-bridge.test.ts +242 -0
  69. package/src/__tests__/session-skill-tools.test.ts +1 -1
  70. package/src/__tests__/shell-identity.test.ts +256 -0
  71. package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
  72. package/src/__tests__/subagent-tools.test.ts +637 -54
  73. package/src/__tests__/task-management-tools.test.ts +936 -0
  74. package/src/__tests__/task-runner.test.ts +2 -2
  75. package/src/__tests__/terminal-tools.test.ts +840 -0
  76. package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
  77. package/src/__tests__/tool-executor.test.ts +85 -151
  78. package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
  79. package/src/__tests__/trust-store.test.ts +27 -453
  80. package/src/__tests__/twilio-provider.test.ts +153 -3
  81. package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
  82. package/src/__tests__/twilio-routes-twiml.test.ts +4 -4
  83. package/src/__tests__/twilio-routes.test.ts +17 -262
  84. package/src/__tests__/twitter-auth-handler.test.ts +2 -1
  85. package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
  86. package/src/__tests__/twitter-cli-routing.test.ts +252 -0
  87. package/src/__tests__/twitter-oauth-client.test.ts +209 -0
  88. package/src/__tests__/workspace-policy.test.ts +213 -0
  89. package/src/calls/call-bridge.ts +92 -19
  90. package/src/calls/call-domain.ts +157 -5
  91. package/src/calls/call-orchestrator.ts +93 -7
  92. package/src/calls/call-store.ts +6 -0
  93. package/src/calls/elevenlabs-client.ts +8 -0
  94. package/src/calls/elevenlabs-config.ts +7 -5
  95. package/src/calls/twilio-provider.ts +91 -0
  96. package/src/calls/twilio-routes.ts +32 -37
  97. package/src/calls/types.ts +3 -1
  98. package/src/calls/voice-quality.ts +29 -7
  99. package/src/cli/twitter.ts +200 -21
  100. package/src/cli.ts +1 -20
  101. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
  102. package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
  103. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
  104. package/src/config/bundled-skills/messaging/SKILL.md +17 -2
  105. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
  106. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  107. package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
  108. package/src/config/bundled-skills/phone-calls/SKILL.md +142 -34
  109. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
  110. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
  111. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
  112. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
  113. package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
  114. package/src/config/bundled-skills/twitter/SKILL.md +103 -17
  115. package/src/config/defaults.ts +10 -4
  116. package/src/config/schema.ts +80 -21
  117. package/src/config/types.ts +1 -0
  118. package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
  119. package/src/daemon/assistant-attachments.ts +4 -2
  120. package/src/daemon/handlers/apps.ts +69 -0
  121. package/src/daemon/handlers/config.ts +543 -24
  122. package/src/daemon/handlers/index.ts +1 -0
  123. package/src/daemon/handlers/sessions.ts +22 -6
  124. package/src/daemon/handlers/shared.ts +2 -1
  125. package/src/daemon/handlers/skills.ts +5 -20
  126. package/src/daemon/ipc-contract-inventory.json +28 -0
  127. package/src/daemon/ipc-contract.ts +168 -10
  128. package/src/daemon/ipc-validate.ts +17 -0
  129. package/src/daemon/lifecycle.ts +2 -0
  130. package/src/daemon/server.ts +78 -72
  131. package/src/daemon/session-attachments.ts +1 -1
  132. package/src/daemon/session-conflict-gate.ts +62 -6
  133. package/src/daemon/session-notifiers.ts +1 -1
  134. package/src/daemon/session-process.ts +62 -3
  135. package/src/daemon/session-tool-setup.ts +1 -2
  136. package/src/daemon/tls-certs.ts +189 -0
  137. package/src/daemon/video-thumbnail.ts +5 -3
  138. package/src/hooks/manager.ts +5 -9
  139. package/src/memory/app-git-service.ts +295 -0
  140. package/src/memory/app-store.ts +21 -0
  141. package/src/memory/conflict-intent.ts +47 -4
  142. package/src/memory/conflict-policy.ts +73 -0
  143. package/src/memory/conflict-store.ts +9 -1
  144. package/src/memory/contradiction-checker.ts +28 -0
  145. package/src/memory/conversation-key-store.ts +15 -0
  146. package/src/memory/db.ts +81 -0
  147. package/src/memory/embedding-local.ts +3 -13
  148. package/src/memory/external-conversation-store.ts +234 -0
  149. package/src/memory/job-handlers/conflict.ts +22 -2
  150. package/src/memory/jobs-worker.ts +67 -28
  151. package/src/memory/runs-store.ts +54 -7
  152. package/src/memory/schema.ts +20 -0
  153. package/src/messaging/provider.ts +9 -0
  154. package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
  155. package/src/messaging/providers/telegram-bot/client.ts +104 -0
  156. package/src/messaging/providers/telegram-bot/types.ts +15 -0
  157. package/src/messaging/registry.ts +1 -0
  158. package/src/permissions/checker.ts +48 -44
  159. package/src/permissions/prompter.ts +0 -4
  160. package/src/permissions/shell-identity.ts +227 -0
  161. package/src/permissions/trust-store.ts +76 -53
  162. package/src/permissions/types.ts +0 -19
  163. package/src/permissions/workspace-policy.ts +114 -0
  164. package/src/providers/retry.ts +12 -37
  165. package/src/runtime/assistant-event-hub.ts +41 -4
  166. package/src/runtime/channel-approval-parser.ts +60 -0
  167. package/src/runtime/channel-approval-types.ts +71 -0
  168. package/src/runtime/channel-approvals.ts +145 -0
  169. package/src/runtime/gateway-client.ts +16 -0
  170. package/src/runtime/http-server.ts +29 -9
  171. package/src/runtime/routes/call-routes.ts +52 -2
  172. package/src/runtime/routes/channel-routes.ts +296 -16
  173. package/src/runtime/routes/events-routes.ts +97 -28
  174. package/src/runtime/routes/run-routes.ts +2 -7
  175. package/src/runtime/run-orchestrator.ts +0 -3
  176. package/src/schedule/recurrence-engine.ts +26 -2
  177. package/src/schedule/recurrence-types.ts +1 -1
  178. package/src/schedule/schedule-store.ts +12 -3
  179. package/src/security/secret-scanner.ts +7 -0
  180. package/src/tasks/ephemeral-permissions.ts +0 -2
  181. package/src/tasks/task-scheduler.ts +2 -1
  182. package/src/tools/calls/call-start.ts +8 -0
  183. package/src/tools/execution-target.ts +21 -0
  184. package/src/tools/execution-timeout.ts +49 -0
  185. package/src/tools/executor.ts +6 -135
  186. package/src/tools/network/web-search.ts +9 -32
  187. package/src/tools/policy-context.ts +29 -0
  188. package/src/tools/schedule/update.ts +8 -1
  189. package/src/tools/terminal/parser.ts +16 -18
  190. package/src/tools/types.ts +4 -11
  191. package/src/twitter/oauth-client.ts +102 -0
  192. package/src/twitter/router.ts +101 -0
  193. package/src/util/debounce.ts +88 -0
  194. package/src/util/network-info.ts +47 -0
  195. package/src/util/platform.ts +29 -4
  196. package/src/util/promise-guard.ts +37 -0
  197. package/src/util/retry.ts +98 -0
  198. package/src/util/truncate.ts +1 -1
  199. package/src/workspace/git-service.ts +129 -112
  200. package/src/tools/contacts/contact-merge.ts +0 -55
  201. package/src/tools/contacts/contact-search.ts +0 -58
  202. package/src/tools/contacts/contact-upsert.ts +0 -64
  203. package/src/tools/playbooks/index.ts +0 -4
  204. package/src/tools/playbooks/playbook-create.ts +0 -96
  205. package/src/tools/playbooks/playbook-delete.ts +0 -52
  206. package/src/tools/playbooks/playbook-list.ts +0 -74
  207. package/src/tools/playbooks/playbook-update.ts +0 -111
@@ -0,0 +1,266 @@
1
+ import { describe, test, expect, beforeEach, afterAll, mock } from 'bun:test';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Test isolation: in-memory SQLite via temp directory
8
+ // ---------------------------------------------------------------------------
9
+
10
+ const testDir = mkdtempSync(join(tmpdir(), 'channel-approval-test-'));
11
+
12
+ mock.module('../util/platform.js', () => ({
13
+ getDataDir: () => testDir,
14
+ isMacOS: () => process.platform === 'darwin',
15
+ isLinux: () => process.platform === 'linux',
16
+ isWindows: () => process.platform === 'win32',
17
+ getSocketPath: () => join(testDir, 'test.sock'),
18
+ getPidPath: () => join(testDir, 'test.pid'),
19
+ getDbPath: () => join(testDir, 'test.db'),
20
+ getLogPath: () => join(testDir, 'test.log'),
21
+ ensureDataDir: () => {},
22
+ }));
23
+
24
+ mock.module('../util/logger.js', () => ({
25
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
26
+ get: () => () => {},
27
+ }),
28
+ }));
29
+
30
+ import { initializeDb, resetDb } from '../memory/db.js';
31
+ import {
32
+ createRun,
33
+ setRunConfirmation,
34
+ getPendingConfirmationsByConversation,
35
+ } from '../memory/runs-store.js';
36
+ import type { PendingConfirmation } from '../memory/runs-store.js';
37
+ import { parseApprovalDecision } from '../runtime/channel-approval-parser.js';
38
+
39
+ initializeDb();
40
+
41
+ afterAll(() => {
42
+ resetDb();
43
+ try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
44
+ });
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Helper: insert a conversation so FK constraints pass
48
+ // ---------------------------------------------------------------------------
49
+
50
+ function ensureConversation(conversationId: string): void {
51
+ const { getDb } = require('../memory/db.js');
52
+ const db = getDb();
53
+ const { conversations } = require('../memory/schema.js');
54
+ try {
55
+ db.insert(conversations).values({
56
+ id: conversationId,
57
+ createdAt: Date.now(),
58
+ updatedAt: Date.now(),
59
+ }).run();
60
+ } catch {
61
+ // already exists
62
+ }
63
+ }
64
+
65
+ function resetTables(): void {
66
+ const { getDb } = require('../memory/db.js');
67
+ const db = getDb();
68
+ db.run('DELETE FROM message_runs');
69
+ db.run('DELETE FROM conversations');
70
+ }
71
+
72
+ // ═══════════════════════════════════════════════════════════════════════════
73
+ // 1. Plain-text approval decision parser
74
+ // ═══════════════════════════════════════════════════════════════════════════
75
+
76
+ describe('parseApprovalDecision', () => {
77
+ // ── Approve once ──────────────────────────────────────────────────
78
+
79
+ test.each([
80
+ 'yes',
81
+ 'Yes',
82
+ 'YES',
83
+ 'approve',
84
+ 'Approve',
85
+ 'APPROVE',
86
+ 'allow',
87
+ 'Allow',
88
+ 'go ahead',
89
+ 'Go Ahead',
90
+ 'GO AHEAD',
91
+ ])('recognises "%s" as approve_once', (input) => {
92
+ const result = parseApprovalDecision(input);
93
+ expect(result).not.toBeNull();
94
+ expect(result!.action).toBe('approve_once');
95
+ expect(result!.source).toBe('plain_text');
96
+ });
97
+
98
+ // ── Approve always ────────────────────────────────────────────────
99
+
100
+ test.each([
101
+ 'always',
102
+ 'Always',
103
+ 'ALWAYS',
104
+ 'approve always',
105
+ 'Approve Always',
106
+ 'APPROVE ALWAYS',
107
+ 'allow always',
108
+ 'Allow Always',
109
+ 'ALLOW ALWAYS',
110
+ ])('recognises "%s" as approve_always', (input) => {
111
+ const result = parseApprovalDecision(input);
112
+ expect(result).not.toBeNull();
113
+ expect(result!.action).toBe('approve_always');
114
+ expect(result!.source).toBe('plain_text');
115
+ });
116
+
117
+ // ── Reject ────────────────────────────────────────────────────────
118
+
119
+ test.each([
120
+ 'no',
121
+ 'No',
122
+ 'NO',
123
+ 'reject',
124
+ 'Reject',
125
+ 'REJECT',
126
+ 'deny',
127
+ 'Deny',
128
+ 'DENY',
129
+ 'cancel',
130
+ 'Cancel',
131
+ 'CANCEL',
132
+ ])('recognises "%s" as reject', (input) => {
133
+ const result = parseApprovalDecision(input);
134
+ expect(result).not.toBeNull();
135
+ expect(result!.action).toBe('reject');
136
+ expect(result!.source).toBe('plain_text');
137
+ });
138
+
139
+ // ── Whitespace handling ───────────────────────────────────────────
140
+
141
+ test('trims leading and trailing whitespace', () => {
142
+ const result = parseApprovalDecision(' approve ');
143
+ expect(result).not.toBeNull();
144
+ expect(result!.action).toBe('approve_once');
145
+ });
146
+
147
+ test('trims tabs and newlines', () => {
148
+ const result = parseApprovalDecision('\t\nreject\n\t');
149
+ expect(result).not.toBeNull();
150
+ expect(result!.action).toBe('reject');
151
+ });
152
+
153
+ // ── Non-matching text ─────────────────────────────────────────────
154
+
155
+ test.each([
156
+ '',
157
+ ' ',
158
+ 'hello',
159
+ 'please approve this',
160
+ 'I approve',
161
+ 'yes please',
162
+ 'nope',
163
+ 'approved',
164
+ 'allow me',
165
+ 'go',
166
+ 'ahead',
167
+ 'maybe',
168
+ 'approve once',
169
+ ])('returns null for non-matching text: "%s"', (input) => {
170
+ expect(parseApprovalDecision(input)).toBeNull();
171
+ });
172
+ });
173
+
174
+ // ═══════════════════════════════════════════════════════════════════════════
175
+ // 2. Pending-run lookup helpers
176
+ // ═══════════════════════════════════════════════════════════════════════════
177
+
178
+ describe('getPendingConfirmationsByConversation', () => {
179
+ beforeEach(() => {
180
+ resetTables();
181
+ });
182
+
183
+ const sampleConfirmation: PendingConfirmation = {
184
+ toolName: 'shell',
185
+ toolUseId: 'req-abc-123',
186
+ input: { command: 'rm -rf /tmp/test' },
187
+ riskLevel: 'high',
188
+ };
189
+
190
+ test('returns empty array when no runs exist', () => {
191
+ ensureConversation('conv-1');
192
+ const result = getPendingConfirmationsByConversation('conv-1');
193
+ expect(result).toEqual([]);
194
+ });
195
+
196
+ test('returns empty array when no runs need confirmation', () => {
197
+ ensureConversation('conv-1');
198
+ createRun('conv-1', 'msg-1');
199
+ const result = getPendingConfirmationsByConversation('conv-1');
200
+ expect(result).toEqual([]);
201
+ });
202
+
203
+ test('returns pending confirmation for a run needing confirmation', () => {
204
+ ensureConversation('conv-1');
205
+ const run = createRun('conv-1', 'msg-1');
206
+ setRunConfirmation(run.id, sampleConfirmation);
207
+
208
+ const result = getPendingConfirmationsByConversation('conv-1');
209
+ expect(result).toHaveLength(1);
210
+ expect(result[0].runId).toBe(run.id);
211
+ expect(result[0].requestId).toBe('req-abc-123');
212
+ expect(result[0].toolName).toBe('shell');
213
+ expect(result[0].input).toEqual({ command: 'rm -rf /tmp/test' });
214
+ expect(result[0].riskLevel).toBe('high');
215
+ });
216
+
217
+ test('only returns runs for the specified conversation', () => {
218
+ ensureConversation('conv-1');
219
+ ensureConversation('conv-2');
220
+
221
+ const run1 = createRun('conv-1', 'msg-1');
222
+ const run2 = createRun('conv-2', 'msg-2');
223
+ setRunConfirmation(run1.id, sampleConfirmation);
224
+ setRunConfirmation(run2.id, { ...sampleConfirmation, toolUseId: 'req-def-456' });
225
+
226
+ const result1 = getPendingConfirmationsByConversation('conv-1');
227
+ expect(result1).toHaveLength(1);
228
+ expect(result1[0].runId).toBe(run1.id);
229
+
230
+ const result2 = getPendingConfirmationsByConversation('conv-2');
231
+ expect(result2).toHaveLength(1);
232
+ expect(result2[0].runId).toBe(run2.id);
233
+ });
234
+
235
+ test('returns multiple pending runs for the same conversation', () => {
236
+ ensureConversation('conv-1');
237
+
238
+ const run1 = createRun('conv-1', 'msg-1');
239
+ const run2 = createRun('conv-1', 'msg-2');
240
+ setRunConfirmation(run1.id, sampleConfirmation);
241
+ setRunConfirmation(run2.id, { ...sampleConfirmation, toolUseId: 'req-ghi-789', toolName: 'file_edit' });
242
+
243
+ const result = getPendingConfirmationsByConversation('conv-1');
244
+ expect(result).toHaveLength(2);
245
+
246
+ const runIds = result.map((r) => r.runId).sort();
247
+ expect(runIds).toContain(run1.id);
248
+ expect(runIds).toContain(run2.id);
249
+ });
250
+
251
+ test('excludes completed and failed runs', () => {
252
+ ensureConversation('conv-1');
253
+
254
+ const run1 = createRun('conv-1', 'msg-1');
255
+ const run2 = createRun('conv-1', 'msg-2');
256
+ const run3 = createRun('conv-1', 'msg-3');
257
+
258
+ setRunConfirmation(run1.id, sampleConfirmation);
259
+ // run2 stays in 'running' state
260
+ // run3 gets confirmation then completes — simulated by not setting confirmation
261
+
262
+ const result = getPendingConfirmationsByConversation('conv-1');
263
+ expect(result).toHaveLength(1);
264
+ expect(result[0].runId).toBe(run1.id);
265
+ });
266
+ });
@@ -0,0 +1,393 @@
1
+ import { describe, test, expect, beforeEach, afterAll, mock, spyOn } from 'bun:test';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Test isolation: in-memory SQLite via temp directory
8
+ // ---------------------------------------------------------------------------
9
+
10
+ const testDir = mkdtempSync(join(tmpdir(), 'channel-approvals-test-'));
11
+
12
+ mock.module('../util/platform.js', () => ({
13
+ getRootDir: () => testDir,
14
+ getDataDir: () => testDir,
15
+ isMacOS: () => process.platform === 'darwin',
16
+ isLinux: () => process.platform === 'linux',
17
+ isWindows: () => process.platform === 'win32',
18
+ getSocketPath: () => join(testDir, 'test.sock'),
19
+ getPidPath: () => join(testDir, 'test.pid'),
20
+ getDbPath: () => join(testDir, 'test.db'),
21
+ getLogPath: () => join(testDir, 'test.log'),
22
+ ensureDataDir: () => {},
23
+ }));
24
+
25
+ mock.module('../util/logger.js', () => ({
26
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
27
+ get: () => () => {},
28
+ }),
29
+ }));
30
+
31
+ import { initializeDb, getDb, resetDb } from '../memory/db.js';
32
+ import {
33
+ createRun,
34
+ setRunConfirmation,
35
+ } from '../memory/runs-store.js';
36
+ import type { PendingConfirmation, PendingRunInfo } from '../memory/runs-store.js';
37
+ import type { RunOrchestrator } from '../runtime/run-orchestrator.js';
38
+ import {
39
+ getChannelApprovalPrompt,
40
+ buildApprovalUIMetadata,
41
+ handleChannelDecision,
42
+ buildReminderPrompt,
43
+ } from '../runtime/channel-approvals.js';
44
+ import type { ApprovalDecisionResult, ChannelApprovalPrompt } from '../runtime/channel-approval-types.js';
45
+ import * as trustStore from '../permissions/trust-store.js';
46
+
47
+ initializeDb();
48
+
49
+ afterAll(() => {
50
+ resetDb();
51
+ try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
52
+ });
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Helpers
56
+ // ---------------------------------------------------------------------------
57
+
58
+ function ensureConversation(conversationId: string): void {
59
+ const db = getDb();
60
+ try {
61
+ db.run(
62
+ `INSERT INTO conversations (id, createdAt, updatedAt) VALUES (?, ?, ?)`,
63
+ [conversationId, Date.now(), Date.now()],
64
+ );
65
+ } catch {
66
+ // already exists
67
+ }
68
+ }
69
+
70
+ function resetTables(): void {
71
+ const db = getDb();
72
+ db.run('DELETE FROM message_runs');
73
+ db.run('DELETE FROM conversations');
74
+ }
75
+
76
+ const sampleConfirmation: PendingConfirmation = {
77
+ toolName: 'shell',
78
+ toolUseId: 'req-abc-123',
79
+ input: { command: 'rm -rf /tmp/test' },
80
+ riskLevel: 'high',
81
+ allowlistOptions: [{ label: 'rm -rf /tmp/test', pattern: 'rm -rf /tmp/test' }],
82
+ scopeOptions: [{ label: 'everywhere', scope: 'everywhere' }],
83
+ };
84
+
85
+ function makeMockOrchestrator(
86
+ submitResult: 'applied' | 'run_not_found' | 'no_pending_decision' = 'applied',
87
+ ): RunOrchestrator {
88
+ return {
89
+ submitDecision: mock(() => submitResult),
90
+ } as unknown as RunOrchestrator;
91
+ }
92
+
93
+ // ═══════════════════════════════════════════════════════════════════════════
94
+ // 1. getChannelApprovalPrompt
95
+ // ═══════════════════════════════════════════════════════════════════════════
96
+
97
+ describe('getChannelApprovalPrompt', () => {
98
+ beforeEach(() => {
99
+ resetTables();
100
+ });
101
+
102
+ test('returns null when no pending runs exist', () => {
103
+ ensureConversation('conv-1');
104
+ const result = getChannelApprovalPrompt('conv-1');
105
+ expect(result).toBeNull();
106
+ });
107
+
108
+ test('returns null when runs exist but none need confirmation', () => {
109
+ ensureConversation('conv-1');
110
+ createRun('conv-1', 'msg-1');
111
+ const result = getChannelApprovalPrompt('conv-1');
112
+ expect(result).toBeNull();
113
+ });
114
+
115
+ test('returns a prompt when a run needs confirmation', () => {
116
+ ensureConversation('conv-1');
117
+ const run = createRun('conv-1', 'msg-1');
118
+ setRunConfirmation(run.id, sampleConfirmation);
119
+
120
+ const result = getChannelApprovalPrompt('conv-1');
121
+ expect(result).not.toBeNull();
122
+ expect(result!.promptText).toContain('shell');
123
+ expect(result!.actions).toHaveLength(3);
124
+ expect(result!.actions.map((a) => a.id)).toEqual([
125
+ 'approve_once',
126
+ 'approve_always',
127
+ 'reject',
128
+ ]);
129
+ expect(result!.plainTextFallback).toContain('yes');
130
+ expect(result!.plainTextFallback).toContain('always');
131
+ expect(result!.plainTextFallback).toContain('no');
132
+ });
133
+
134
+ test('uses the first pending run when multiple exist', () => {
135
+ ensureConversation('conv-1');
136
+ const run1 = createRun('conv-1', 'msg-1');
137
+ const run2 = createRun('conv-1', 'msg-2');
138
+ setRunConfirmation(run1.id, sampleConfirmation);
139
+ setRunConfirmation(run2.id, {
140
+ ...sampleConfirmation,
141
+ toolName: 'file_edit',
142
+ toolUseId: 'req-def-456',
143
+ });
144
+
145
+ const result = getChannelApprovalPrompt('conv-1');
146
+ expect(result).not.toBeNull();
147
+ // Should contain one of the tool names (the first pending run)
148
+ expect(result!.promptText).toMatch(/shell|file_edit/);
149
+ });
150
+
151
+ test('does not return prompts for other conversations', () => {
152
+ ensureConversation('conv-1');
153
+ ensureConversation('conv-2');
154
+ const run = createRun('conv-1', 'msg-1');
155
+ setRunConfirmation(run.id, sampleConfirmation);
156
+
157
+ const result = getChannelApprovalPrompt('conv-2');
158
+ expect(result).toBeNull();
159
+ });
160
+ });
161
+
162
+ // ═══════════════════════════════════════════════════════════════════════════
163
+ // 2. buildApprovalUIMetadata
164
+ // ═══════════════════════════════════════════════════════════════════════════
165
+
166
+ describe('buildApprovalUIMetadata', () => {
167
+ test('maps prompt and run info to UI metadata', () => {
168
+ const prompt: ChannelApprovalPrompt = {
169
+ promptText: 'Allow shell?',
170
+ actions: [
171
+ { id: 'approve_once', label: 'Approve once' },
172
+ { id: 'reject', label: 'Reject' },
173
+ ],
174
+ plainTextFallback: 'Reply yes or no.',
175
+ };
176
+
177
+ const runInfo: PendingRunInfo = {
178
+ runId: 'run-123',
179
+ requestId: 'req-abc',
180
+ toolName: 'shell',
181
+ input: { command: 'ls' },
182
+ riskLevel: 'low',
183
+ };
184
+
185
+ const metadata = buildApprovalUIMetadata(prompt, runInfo);
186
+ expect(metadata.runId).toBe('run-123');
187
+ expect(metadata.requestId).toBe('req-abc');
188
+ expect(metadata.actions).toEqual(prompt.actions);
189
+ expect(metadata.plainTextFallback).toBe('Reply yes or no.');
190
+ });
191
+ });
192
+
193
+ // ═══════════════════════════════════════════════════════════════════════════
194
+ // 3. handleChannelDecision
195
+ // ═══════════════════════════════════════════════════════════════════════════
196
+
197
+ describe('handleChannelDecision', () => {
198
+ beforeEach(() => {
199
+ resetTables();
200
+ });
201
+
202
+ test('returns applied: false when no pending runs exist', () => {
203
+ ensureConversation('conv-1');
204
+ const orchestrator = makeMockOrchestrator();
205
+ const decision: ApprovalDecisionResult = {
206
+ action: 'approve_once',
207
+ source: 'plain_text',
208
+ };
209
+
210
+ const result = handleChannelDecision('conv-1', decision, orchestrator);
211
+ expect(result.applied).toBe(false);
212
+ expect(result.runId).toBeUndefined();
213
+ });
214
+
215
+ test('approves once via orchestrator.submitDecision with "allow"', () => {
216
+ ensureConversation('conv-1');
217
+ const run = createRun('conv-1', 'msg-1');
218
+ setRunConfirmation(run.id, sampleConfirmation);
219
+
220
+ const orchestrator = makeMockOrchestrator();
221
+ const decision: ApprovalDecisionResult = {
222
+ action: 'approve_once',
223
+ source: 'plain_text',
224
+ };
225
+
226
+ const result = handleChannelDecision('conv-1', decision, orchestrator);
227
+ expect(result.applied).toBe(true);
228
+ expect(result.runId).toBe(run.id);
229
+ expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'allow');
230
+ });
231
+
232
+ test('rejects via orchestrator.submitDecision with "deny"', () => {
233
+ ensureConversation('conv-1');
234
+ const run = createRun('conv-1', 'msg-1');
235
+ setRunConfirmation(run.id, sampleConfirmation);
236
+
237
+ const orchestrator = makeMockOrchestrator();
238
+ const decision: ApprovalDecisionResult = {
239
+ action: 'reject',
240
+ source: 'telegram_button',
241
+ };
242
+
243
+ const result = handleChannelDecision('conv-1', decision, orchestrator);
244
+ expect(result.applied).toBe(true);
245
+ expect(result.runId).toBe(run.id);
246
+ expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'deny');
247
+ });
248
+
249
+ test('approve_always adds a trust rule and submits "allow"', () => {
250
+ ensureConversation('conv-1');
251
+ const run = createRun('conv-1', 'msg-1');
252
+ setRunConfirmation(run.id, {
253
+ ...sampleConfirmation,
254
+ executionTarget: 'sandbox',
255
+ allowlistOptions: [{ label: 'rm pattern', pattern: 'rm -rf *' }],
256
+ scopeOptions: [{ label: 'project dir', scope: '/tmp/project' }],
257
+ });
258
+
259
+ const addRuleSpy = spyOn(trustStore, 'addRule');
260
+ const orchestrator = makeMockOrchestrator();
261
+ const decision: ApprovalDecisionResult = {
262
+ action: 'approve_always',
263
+ source: 'plain_text',
264
+ };
265
+
266
+ const result = handleChannelDecision('conv-1', decision, orchestrator);
267
+ expect(result.applied).toBe(true);
268
+ expect(result.runId).toBe(run.id);
269
+
270
+ // Trust rule added with first allowlist and scope option
271
+ expect(addRuleSpy).toHaveBeenCalledWith(
272
+ 'shell',
273
+ 'rm -rf *',
274
+ '/tmp/project',
275
+ 'allow',
276
+ 100,
277
+ { executionTarget: 'sandbox' },
278
+ );
279
+
280
+ // The run is still approved with a simple "allow"
281
+ expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'allow');
282
+
283
+ addRuleSpy.mockRestore();
284
+ });
285
+
286
+ test('approve_always falls back to "**" / "everywhere" when no options available', () => {
287
+ ensureConversation('conv-1');
288
+ const run = createRun('conv-1', 'msg-1');
289
+ setRunConfirmation(run.id, {
290
+ toolName: 'bash',
291
+ toolUseId: 'req-no-opts',
292
+ input: { command: 'echo hi' },
293
+ riskLevel: 'low',
294
+ // No allowlistOptions or scopeOptions
295
+ });
296
+
297
+ const addRuleSpy = spyOn(trustStore, 'addRule');
298
+ const orchestrator = makeMockOrchestrator();
299
+ const decision: ApprovalDecisionResult = {
300
+ action: 'approve_always',
301
+ source: 'telegram_button',
302
+ };
303
+
304
+ handleChannelDecision('conv-1', decision, orchestrator);
305
+
306
+ expect(addRuleSpy).toHaveBeenCalledWith(
307
+ 'bash',
308
+ '**',
309
+ 'everywhere',
310
+ 'allow',
311
+ 100,
312
+ { executionTarget: undefined },
313
+ );
314
+
315
+ addRuleSpy.mockRestore();
316
+ });
317
+
318
+ test('returns applied: false when orchestrator cannot apply decision', () => {
319
+ ensureConversation('conv-1');
320
+ const run = createRun('conv-1', 'msg-1');
321
+ setRunConfirmation(run.id, sampleConfirmation);
322
+
323
+ const orchestrator = makeMockOrchestrator('no_pending_decision');
324
+ const decision: ApprovalDecisionResult = {
325
+ action: 'approve_once',
326
+ source: 'plain_text',
327
+ };
328
+
329
+ const result = handleChannelDecision('conv-1', decision, orchestrator);
330
+ expect(result.applied).toBe(false);
331
+ expect(result.runId).toBe(run.id);
332
+ });
333
+ });
334
+
335
+ // ═══════════════════════════════════════════════════════════════════════════
336
+ // 4. buildReminderPrompt
337
+ // ═══════════════════════════════════════════════════════════════════════════
338
+
339
+ describe('buildReminderPrompt', () => {
340
+ test('prefixes promptText with a reminder', () => {
341
+ const original: ChannelApprovalPrompt = {
342
+ promptText: 'Allow shell?',
343
+ actions: [
344
+ { id: 'approve_once', label: 'Approve once' },
345
+ { id: 'reject', label: 'Reject' },
346
+ ],
347
+ plainTextFallback: 'Reply yes or no.',
348
+ };
349
+
350
+ const reminder = buildReminderPrompt(original);
351
+ expect(reminder.promptText).toContain("I'm still waiting");
352
+ expect(reminder.promptText).toContain('Allow shell?');
353
+ });
354
+
355
+ test('preserves the original actions', () => {
356
+ const original: ChannelApprovalPrompt = {
357
+ promptText: 'Approve file_edit?',
358
+ actions: [
359
+ { id: 'approve_once', label: 'Approve once' },
360
+ { id: 'approve_always', label: 'Approve always' },
361
+ { id: 'reject', label: 'Reject' },
362
+ ],
363
+ plainTextFallback: 'Reply yes, always, or no.',
364
+ };
365
+
366
+ const reminder = buildReminderPrompt(original);
367
+ expect(reminder.actions).toEqual(original.actions);
368
+ });
369
+
370
+ test('prefixes plainTextFallback with a reminder', () => {
371
+ const original: ChannelApprovalPrompt = {
372
+ promptText: 'Allow bash?',
373
+ actions: [],
374
+ plainTextFallback: 'Reply yes or no.',
375
+ };
376
+
377
+ const reminder = buildReminderPrompt(original);
378
+ expect(reminder.plainTextFallback).toContain("I'm still waiting");
379
+ expect(reminder.plainTextFallback).toContain('Reply yes or no.');
380
+ });
381
+
382
+ test('does not mutate the original prompt', () => {
383
+ const original: ChannelApprovalPrompt = {
384
+ promptText: 'Allow grep?',
385
+ actions: [{ id: 'approve_once', label: 'Approve once' }],
386
+ plainTextFallback: 'Reply yes.',
387
+ };
388
+
389
+ const originalText = original.promptText;
390
+ buildReminderPrompt(original);
391
+ expect(original.promptText).toBe(originalText);
392
+ });
393
+ });