@vellumai/assistant 0.3.19 → 0.3.20

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 (189) hide show
  1. package/ARCHITECTURE.md +151 -15
  2. package/Dockerfile +1 -0
  3. package/README.md +40 -4
  4. package/docs/architecture/integrations.md +7 -11
  5. package/package.json +1 -1
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +54 -0
  7. package/src/__tests__/approval-primitive.test.ts +540 -0
  8. package/src/__tests__/assistant-feature-flag-guard.test.ts +206 -0
  9. package/src/__tests__/assistant-feature-flag-guardrails.test.ts +198 -0
  10. package/src/__tests__/assistant-feature-flags-integration.test.ts +272 -0
  11. package/src/__tests__/call-controller.test.ts +439 -108
  12. package/src/__tests__/channel-invite-transport.test.ts +264 -0
  13. package/src/__tests__/cli.test.ts +42 -1
  14. package/src/__tests__/config-schema.test.ts +11 -127
  15. package/src/__tests__/config-watcher.test.ts +0 -8
  16. package/src/__tests__/daemon-lifecycle.test.ts +1 -0
  17. package/src/__tests__/daemon-server-session-init.test.ts +8 -2
  18. package/src/__tests__/diff.test.ts +22 -0
  19. package/src/__tests__/guardian-action-copy-generator.test.ts +5 -0
  20. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +300 -32
  21. package/src/__tests__/guardian-action-late-reply.test.ts +546 -1
  22. package/src/__tests__/guardian-actions-endpoint.test.ts +774 -0
  23. package/src/__tests__/guardian-control-plane-policy.test.ts +36 -3
  24. package/src/__tests__/guardian-dispatch.test.ts +124 -0
  25. package/src/__tests__/guardian-grant-minting.test.ts +6 -17
  26. package/src/__tests__/inbound-invite-redemption.test.ts +367 -0
  27. package/src/__tests__/invite-redemption-service.test.ts +306 -0
  28. package/src/__tests__/ipc-snapshot.test.ts +57 -0
  29. package/src/__tests__/notification-decision-fallback.test.ts +88 -0
  30. package/src/__tests__/sandbox-diagnostics.test.ts +6 -249
  31. package/src/__tests__/sandbox-host-parity.test.ts +6 -13
  32. package/src/__tests__/scoped-approval-grants.test.ts +6 -6
  33. package/src/__tests__/scoped-grant-security-matrix.test.ts +5 -4
  34. package/src/__tests__/script-proxy-session-manager.test.ts +1 -19
  35. package/src/__tests__/session-load-history-repair.test.ts +169 -2
  36. package/src/__tests__/session-runtime-assembly.test.ts +33 -5
  37. package/src/__tests__/skill-feature-flags-integration.test.ts +171 -0
  38. package/src/__tests__/skill-feature-flags.test.ts +188 -0
  39. package/src/__tests__/skill-load-feature-flag.test.ts +141 -0
  40. package/src/__tests__/skill-mirror-parity.test.ts +1 -0
  41. package/src/__tests__/skill-projection-feature-flag.test.ts +363 -0
  42. package/src/__tests__/system-prompt.test.ts +1 -1
  43. package/src/__tests__/terminal-sandbox.test.ts +142 -9
  44. package/src/__tests__/terminal-tools.test.ts +2 -93
  45. package/src/__tests__/thread-seed-composer.test.ts +18 -0
  46. package/src/__tests__/tool-approval-handler.test.ts +350 -0
  47. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
  48. package/src/__tests__/voice-scoped-grant-consumer.test.ts +46 -84
  49. package/src/agent/loop.ts +36 -1
  50. package/src/approvals/approval-primitive.ts +381 -0
  51. package/src/approvals/guardian-decision-primitive.ts +191 -0
  52. package/src/calls/call-controller.ts +252 -209
  53. package/src/calls/call-domain.ts +44 -6
  54. package/src/calls/guardian-dispatch.ts +48 -0
  55. package/src/calls/types.ts +1 -1
  56. package/src/calls/voice-session-bridge.ts +46 -30
  57. package/src/cli/core-commands.ts +0 -4
  58. package/src/cli.ts +76 -34
  59. package/src/config/__tests__/feature-flag-registry-guard.test.ts +179 -0
  60. package/src/config/assistant-feature-flags.ts +162 -0
  61. package/src/config/bundled-skills/api-mapping/icon.svg +18 -0
  62. package/src/config/bundled-skills/messaging/TOOLS.json +30 -0
  63. package/src/config/bundled-skills/messaging/tools/slack-delete-message.ts +24 -0
  64. package/src/config/bundled-skills/notifications/SKILL.md +1 -1
  65. package/src/config/bundled-skills/reminder/SKILL.md +49 -2
  66. package/src/config/bundled-skills/time-based-actions/SKILL.md +49 -2
  67. package/src/config/bundled-skills/voice-setup/SKILL.md +122 -0
  68. package/src/config/core-schema.ts +1 -1
  69. package/src/config/env-registry.ts +10 -0
  70. package/src/config/feature-flag-registry.json +61 -0
  71. package/src/config/loader.ts +22 -1
  72. package/src/config/sandbox-schema.ts +0 -39
  73. package/src/config/schema.ts +6 -2
  74. package/src/config/skill-state.ts +34 -0
  75. package/src/config/skills-schema.ts +0 -1
  76. package/src/config/skills.ts +9 -0
  77. package/src/config/system-prompt.ts +110 -46
  78. package/src/config/templates/SOUL.md +1 -1
  79. package/src/config/types.ts +19 -1
  80. package/src/config/vellum-skills/catalog.json +1 -1
  81. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
  82. package/src/config/vellum-skills/sms-setup/SKILL.md +1 -1
  83. package/src/config/vellum-skills/telegram-setup/SKILL.md +1 -1
  84. package/src/config/vellum-skills/trusted-contacts/SKILL.md +104 -3
  85. package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
  86. package/src/daemon/config-watcher.ts +0 -1
  87. package/src/daemon/daemon-control.ts +1 -1
  88. package/src/daemon/guardian-invite-intent.ts +124 -0
  89. package/src/daemon/handlers/avatar.ts +68 -0
  90. package/src/daemon/handlers/browser.ts +2 -2
  91. package/src/daemon/handlers/guardian-actions.ts +120 -0
  92. package/src/daemon/handlers/index.ts +4 -0
  93. package/src/daemon/handlers/sessions.ts +19 -0
  94. package/src/daemon/handlers/shared.ts +3 -1
  95. package/src/daemon/install-cli-launchers.ts +58 -13
  96. package/src/daemon/ipc-contract/guardian-actions.ts +53 -0
  97. package/src/daemon/ipc-contract/sessions.ts +8 -2
  98. package/src/daemon/ipc-contract/settings.ts +25 -2
  99. package/src/daemon/ipc-contract-inventory.json +10 -0
  100. package/src/daemon/ipc-contract.ts +4 -0
  101. package/src/daemon/lifecycle.ts +6 -2
  102. package/src/daemon/main.ts +1 -0
  103. package/src/daemon/server.ts +1 -0
  104. package/src/daemon/session-lifecycle.ts +52 -7
  105. package/src/daemon/session-memory.ts +45 -0
  106. package/src/daemon/session-process.ts +258 -432
  107. package/src/daemon/session-runtime-assembly.ts +12 -0
  108. package/src/daemon/session-skill-tools.ts +14 -1
  109. package/src/daemon/session-tool-setup.ts +5 -0
  110. package/src/daemon/session.ts +11 -0
  111. package/src/daemon/tool-side-effects.ts +35 -9
  112. package/src/index.ts +0 -2
  113. package/src/memory/conversation-display-order-migration.ts +44 -0
  114. package/src/memory/conversation-queries.ts +2 -0
  115. package/src/memory/conversation-store.ts +91 -0
  116. package/src/memory/db-init.ts +5 -1
  117. package/src/memory/embedding-local.ts +13 -8
  118. package/src/memory/guardian-action-store.ts +125 -2
  119. package/src/memory/ingress-invite-store.ts +95 -1
  120. package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
  121. package/src/memory/migrations/index.ts +2 -1
  122. package/src/memory/schema.ts +5 -1
  123. package/src/memory/scoped-approval-grants.ts +14 -5
  124. package/src/messaging/providers/slack/client.ts +12 -0
  125. package/src/messaging/providers/slack/types.ts +5 -0
  126. package/src/notifications/decision-engine.ts +49 -12
  127. package/src/notifications/emit-signal.ts +7 -0
  128. package/src/notifications/signal.ts +7 -0
  129. package/src/notifications/thread-seed-composer.ts +2 -1
  130. package/src/runtime/channel-approval-types.ts +16 -6
  131. package/src/runtime/channel-approvals.ts +19 -15
  132. package/src/runtime/channel-invite-transport.ts +85 -0
  133. package/src/runtime/channel-invite-transports/telegram.ts +105 -0
  134. package/src/runtime/guardian-action-grant-minter.ts +92 -35
  135. package/src/runtime/guardian-action-message-composer.ts +30 -0
  136. package/src/runtime/guardian-decision-types.ts +91 -0
  137. package/src/runtime/http-server.ts +23 -1
  138. package/src/runtime/ingress-service.ts +22 -0
  139. package/src/runtime/invite-redemption-service.ts +181 -0
  140. package/src/runtime/invite-redemption-templates.ts +39 -0
  141. package/src/runtime/routes/call-routes.ts +2 -1
  142. package/src/runtime/routes/guardian-action-routes.ts +206 -0
  143. package/src/runtime/routes/guardian-approval-interception.ts +66 -190
  144. package/src/runtime/routes/inbound-message-handler.ts +486 -394
  145. package/src/runtime/routes/pairing-routes.ts +4 -0
  146. package/src/security/encrypted-store.ts +31 -17
  147. package/src/security/keychain.ts +176 -2
  148. package/src/security/secure-keys.ts +97 -0
  149. package/src/security/tool-approval-digest.ts +1 -1
  150. package/src/tools/browser/browser-execution.ts +2 -2
  151. package/src/tools/browser/browser-manager.ts +46 -32
  152. package/src/tools/browser/browser-screencast.ts +2 -2
  153. package/src/tools/calls/call-start.ts +1 -1
  154. package/src/tools/executor.ts +22 -17
  155. package/src/tools/network/script-proxy/session-manager.ts +1 -5
  156. package/src/tools/skills/load.ts +22 -8
  157. package/src/tools/system/avatar-generator.ts +119 -0
  158. package/src/tools/system/navigate-settings.ts +65 -0
  159. package/src/tools/system/open-system-settings.ts +75 -0
  160. package/src/tools/system/voice-config.ts +121 -32
  161. package/src/tools/terminal/backends/native.ts +40 -19
  162. package/src/tools/terminal/backends/types.ts +3 -3
  163. package/src/tools/terminal/parser.ts +1 -1
  164. package/src/tools/terminal/sandbox-diagnostics.ts +6 -87
  165. package/src/tools/terminal/sandbox.ts +1 -12
  166. package/src/tools/terminal/shell.ts +3 -31
  167. package/src/tools/tool-approval-handler.ts +141 -3
  168. package/src/tools/tool-manifest.ts +6 -0
  169. package/src/tools/types.ts +6 -0
  170. package/src/util/diff.ts +36 -13
  171. package/Dockerfile.sandbox +0 -5
  172. package/src/__tests__/doordash-client.test.ts +0 -187
  173. package/src/__tests__/doordash-session.test.ts +0 -154
  174. package/src/__tests__/signup-e2e.test.ts +0 -354
  175. package/src/__tests__/terminal-sandbox-docker.test.ts +0 -1065
  176. package/src/__tests__/terminal-sandbox.integration.test.ts +0 -180
  177. package/src/cli/doordash.ts +0 -1057
  178. package/src/config/bundled-skills/doordash/SKILL.md +0 -163
  179. package/src/config/templates/LOOKS.md +0 -25
  180. package/src/doordash/cart-queries.ts +0 -787
  181. package/src/doordash/client.ts +0 -1016
  182. package/src/doordash/order-queries.ts +0 -85
  183. package/src/doordash/queries.ts +0 -13
  184. package/src/doordash/query-extractor.ts +0 -94
  185. package/src/doordash/search-queries.ts +0 -203
  186. package/src/doordash/session.ts +0 -84
  187. package/src/doordash/store-queries.ts +0 -246
  188. package/src/doordash/types.ts +0 -367
  189. package/src/tools/terminal/backends/docker.ts +0 -379
@@ -0,0 +1,540 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
6
+
7
+ const testDir = mkdtempSync(join(tmpdir(), 'approval-primitive-test-'));
8
+
9
+ mock.module('../util/platform.js', () => ({
10
+ getDataDir: () => testDir,
11
+ isMacOS: () => process.platform === 'darwin',
12
+ isLinux: () => process.platform === 'linux',
13
+ isWindows: () => process.platform === 'win32',
14
+ getSocketPath: () => join(testDir, 'test.sock'),
15
+ getPidPath: () => join(testDir, 'test.pid'),
16
+ getDbPath: () => join(testDir, 'test.db'),
17
+ getLogPath: () => join(testDir, 'test.log'),
18
+ ensureDataDir: () => {},
19
+ migrateToDataLayout: () => {},
20
+ migrateToWorkspaceLayout: () => {},
21
+ }));
22
+
23
+ mock.module('../util/logger.js', () => ({
24
+ getLogger: () =>
25
+ new Proxy({} as Record<string, unknown>, {
26
+ get: () => () => {},
27
+ }),
28
+ isDebug: () => false,
29
+ truncateForLog: (value: string) => value,
30
+ }));
31
+
32
+ import {
33
+ consumeGrantForInvocation,
34
+ mintGrantFromDecision,
35
+ type MintGrantParams,
36
+ } from '../approvals/approval-primitive.js';
37
+ import { getDb, initializeDb, resetDb } from '../memory/db.js';
38
+ import { scopedApprovalGrants } from '../memory/schema.js';
39
+ import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
40
+
41
+ initializeDb();
42
+
43
+ function clearTables(): void {
44
+ const db = getDb();
45
+ db.delete(scopedApprovalGrants).run();
46
+ }
47
+
48
+ afterAll(() => {
49
+ resetDb();
50
+ try {
51
+ rmSync(testDir, { recursive: true });
52
+ } catch {
53
+ /* best effort */
54
+ }
55
+ });
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Helper to build mint params with sensible defaults
59
+ // ---------------------------------------------------------------------------
60
+
61
+ function mintParams(overrides: Partial<MintGrantParams> = {}): MintGrantParams {
62
+ const futureExpiry = new Date(Date.now() + 60_000).toISOString();
63
+ return {
64
+ assistantId: 'self',
65
+ scopeMode: 'request_id',
66
+ requestChannel: 'telegram',
67
+ decisionChannel: 'telegram',
68
+ expiresAt: futureExpiry,
69
+ ...overrides,
70
+ };
71
+ }
72
+
73
+ // ===========================================================================
74
+ // MINT TESTS
75
+ // ===========================================================================
76
+
77
+ describe('approval-primitive / mintGrantFromDecision', () => {
78
+ beforeEach(() => clearTables());
79
+
80
+ test('mints a request_id scoped grant successfully', () => {
81
+ const result = mintGrantFromDecision(
82
+ mintParams({ scopeMode: 'request_id', requestId: 'req-1' }),
83
+ );
84
+ expect(result.ok).toBe(true);
85
+ if (!result.ok) return;
86
+ expect(result.grant.status).toBe('active');
87
+ expect(result.grant.requestId).toBe('req-1');
88
+ expect(result.grant.scopeMode).toBe('request_id');
89
+ });
90
+
91
+ test('mints a tool_signature scoped grant successfully', () => {
92
+ const digest = computeToolApprovalDigest('shell', { command: 'ls' });
93
+ const result = mintGrantFromDecision(
94
+ mintParams({
95
+ scopeMode: 'tool_signature',
96
+ toolName: 'shell',
97
+ inputDigest: digest,
98
+ }),
99
+ );
100
+ expect(result.ok).toBe(true);
101
+ if (!result.ok) return;
102
+ expect(result.grant.toolName).toBe('shell');
103
+ expect(result.grant.inputDigest).toBe(digest);
104
+ expect(result.grant.scopeMode).toBe('tool_signature');
105
+ });
106
+
107
+ test('rejects request_id scope when requestId is missing', () => {
108
+ const result = mintGrantFromDecision(
109
+ mintParams({ scopeMode: 'request_id', requestId: null }),
110
+ );
111
+ expect(result.ok).toBe(false);
112
+ if (result.ok) return;
113
+ expect(result.reason).toBe('missing_request_id');
114
+ });
115
+
116
+ test('rejects tool_signature scope when toolName is missing', () => {
117
+ const result = mintGrantFromDecision(
118
+ mintParams({
119
+ scopeMode: 'tool_signature',
120
+ toolName: null,
121
+ inputDigest: 'abc123',
122
+ }),
123
+ );
124
+ expect(result.ok).toBe(false);
125
+ if (result.ok) return;
126
+ expect(result.reason).toBe('missing_tool_fields');
127
+ });
128
+
129
+ test('rejects tool_signature scope when inputDigest is missing', () => {
130
+ const result = mintGrantFromDecision(
131
+ mintParams({
132
+ scopeMode: 'tool_signature',
133
+ toolName: 'shell',
134
+ inputDigest: null,
135
+ }),
136
+ );
137
+ expect(result.ok).toBe(false);
138
+ if (result.ok) return;
139
+ expect(result.reason).toBe('missing_tool_fields');
140
+ });
141
+
142
+ test('mints grant with full scope context fields', () => {
143
+ const result = mintGrantFromDecision(
144
+ mintParams({
145
+ scopeMode: 'request_id',
146
+ requestId: 'req-full',
147
+ conversationId: 'conv-1',
148
+ callSessionId: 'call-1',
149
+ requesterExternalUserId: 'user-1',
150
+ guardianExternalUserId: 'guardian-1',
151
+ executionChannel: 'voice',
152
+ }),
153
+ );
154
+ expect(result.ok).toBe(true);
155
+ if (!result.ok) return;
156
+ expect(result.grant.conversationId).toBe('conv-1');
157
+ expect(result.grant.callSessionId).toBe('call-1');
158
+ expect(result.grant.requesterExternalUserId).toBe('user-1');
159
+ expect(result.grant.guardianExternalUserId).toBe('guardian-1');
160
+ expect(result.grant.executionChannel).toBe('voice');
161
+ });
162
+ });
163
+
164
+ // ===========================================================================
165
+ // CONSUME TESTS
166
+ // ===========================================================================
167
+
168
+ describe('approval-primitive / consumeGrantForInvocation', () => {
169
+ beforeEach(() => clearTables());
170
+
171
+ test('consumes a request_id grant when requestId matches', async () => {
172
+ mintGrantFromDecision(mintParams({ scopeMode: 'request_id', requestId: 'req-100' }));
173
+
174
+ const result = await consumeGrantForInvocation({
175
+ requestId: 'req-100',
176
+ toolName: 'shell',
177
+ inputDigest: computeToolApprovalDigest('shell', { command: 'ls' }),
178
+ consumingRequestId: 'consumer-1',
179
+ assistantId: 'self',
180
+ });
181
+
182
+ expect(result.ok).toBe(true);
183
+ if (!result.ok) return;
184
+ expect(result.grant.status).toBe('consumed');
185
+ expect(result.grant.consumedByRequestId).toBe('consumer-1');
186
+ });
187
+
188
+ test('consumes a tool_signature grant when tool+input matches', async () => {
189
+ const digest = computeToolApprovalDigest('shell', { command: 'ls' });
190
+ mintGrantFromDecision(
191
+ mintParams({
192
+ scopeMode: 'tool_signature',
193
+ toolName: 'shell',
194
+ inputDigest: digest,
195
+ }),
196
+ );
197
+
198
+ const result = await consumeGrantForInvocation({
199
+ toolName: 'shell',
200
+ inputDigest: digest,
201
+ consumingRequestId: 'consumer-2',
202
+ assistantId: 'self',
203
+ });
204
+
205
+ expect(result.ok).toBe(true);
206
+ if (!result.ok) return;
207
+ expect(result.grant.status).toBe('consumed');
208
+ });
209
+
210
+ test('falls back to tool_signature when request_id does not match', async () => {
211
+ const digest = computeToolApprovalDigest('shell', { command: 'ls' });
212
+ // Mint a tool_signature grant (not request_id)
213
+ mintGrantFromDecision(
214
+ mintParams({
215
+ scopeMode: 'tool_signature',
216
+ toolName: 'shell',
217
+ inputDigest: digest,
218
+ }),
219
+ );
220
+
221
+ const result = await consumeGrantForInvocation({
222
+ requestId: 'nonexistent-req',
223
+ toolName: 'shell',
224
+ inputDigest: digest,
225
+ consumingRequestId: 'consumer-3',
226
+ assistantId: 'self',
227
+ });
228
+
229
+ expect(result.ok).toBe(true);
230
+ if (!result.ok) return;
231
+ expect(result.grant.scopeMode).toBe('tool_signature');
232
+ });
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // Consume miss scenarios
236
+ // ---------------------------------------------------------------------------
237
+
238
+ test('miss: no grants exist at all', async () => {
239
+ const result = await consumeGrantForInvocation({
240
+ toolName: 'shell',
241
+ inputDigest: computeToolApprovalDigest('shell', { command: 'ls' }),
242
+ consumingRequestId: 'consumer-miss',
243
+ assistantId: 'self',
244
+ }, { maxWaitMs: 0 });
245
+
246
+ expect(result.ok).toBe(false);
247
+ if (result.ok) return;
248
+ expect(result.reason).toBe('no_match');
249
+ });
250
+
251
+ test('miss: tool name mismatch', async () => {
252
+ const digest = computeToolApprovalDigest('shell', { command: 'ls' });
253
+ mintGrantFromDecision(
254
+ mintParams({
255
+ scopeMode: 'tool_signature',
256
+ toolName: 'shell',
257
+ inputDigest: digest,
258
+ }),
259
+ );
260
+
261
+ const result = await consumeGrantForInvocation({
262
+ toolName: 'file_write',
263
+ inputDigest: digest,
264
+ consumingRequestId: 'consumer-mismatch-tool',
265
+ assistantId: 'self',
266
+ }, { maxWaitMs: 0 });
267
+
268
+ expect(result.ok).toBe(false);
269
+ if (result.ok) return;
270
+ expect(result.reason).toBe('no_match');
271
+ });
272
+
273
+ test('miss: input digest mismatch', async () => {
274
+ mintGrantFromDecision(
275
+ mintParams({
276
+ scopeMode: 'tool_signature',
277
+ toolName: 'shell',
278
+ inputDigest: computeToolApprovalDigest('shell', { command: 'ls' }),
279
+ }),
280
+ );
281
+
282
+ const result = await consumeGrantForInvocation({
283
+ toolName: 'shell',
284
+ inputDigest: computeToolApprovalDigest('shell', { command: 'rm -rf /' }),
285
+ consumingRequestId: 'consumer-mismatch-input',
286
+ assistantId: 'self',
287
+ }, { maxWaitMs: 0 });
288
+
289
+ expect(result.ok).toBe(false);
290
+ if (result.ok) return;
291
+ expect(result.reason).toBe('no_match');
292
+ });
293
+
294
+ test('miss: assistant ID mismatch', async () => {
295
+ mintGrantFromDecision(
296
+ mintParams({
297
+ scopeMode: 'request_id',
298
+ requestId: 'req-assist',
299
+ assistantId: 'assistant-A',
300
+ }),
301
+ );
302
+
303
+ const result = await consumeGrantForInvocation({
304
+ requestId: 'req-assist',
305
+ toolName: 'shell',
306
+ inputDigest: computeToolApprovalDigest('shell', {}),
307
+ consumingRequestId: 'consumer-assist-mismatch',
308
+ assistantId: 'assistant-B',
309
+ }, { maxWaitMs: 0 });
310
+
311
+ expect(result.ok).toBe(false);
312
+ if (result.ok) return;
313
+ expect(result.reason).toBe('no_match');
314
+ });
315
+
316
+ test('miss: grant expired', async () => {
317
+ const pastExpiry = new Date(Date.now() - 60_000).toISOString();
318
+ mintGrantFromDecision(
319
+ mintParams({
320
+ scopeMode: 'request_id',
321
+ requestId: 'req-expired',
322
+ expiresAt: pastExpiry,
323
+ }),
324
+ );
325
+
326
+ const result = await consumeGrantForInvocation({
327
+ requestId: 'req-expired',
328
+ toolName: 'shell',
329
+ inputDigest: computeToolApprovalDigest('shell', {}),
330
+ consumingRequestId: 'consumer-expired',
331
+ assistantId: 'self',
332
+ }, { maxWaitMs: 0 });
333
+
334
+ expect(result.ok).toBe(false);
335
+ if (result.ok) return;
336
+ expect(result.reason).toBe('no_match');
337
+ });
338
+
339
+ // ---------------------------------------------------------------------------
340
+ // One-time consume semantics
341
+ // ---------------------------------------------------------------------------
342
+
343
+ test('one-time consume: second consume of the same grant fails', async () => {
344
+ mintGrantFromDecision(
345
+ mintParams({ scopeMode: 'request_id', requestId: 'req-once' }),
346
+ );
347
+
348
+ const first = await consumeGrantForInvocation({
349
+ requestId: 'req-once',
350
+ toolName: 'shell',
351
+ inputDigest: computeToolApprovalDigest('shell', {}),
352
+ consumingRequestId: 'consumer-first',
353
+ assistantId: 'self',
354
+ });
355
+ expect(first.ok).toBe(true);
356
+
357
+ const second = await consumeGrantForInvocation({
358
+ requestId: 'req-once',
359
+ toolName: 'shell',
360
+ inputDigest: computeToolApprovalDigest('shell', {}),
361
+ consumingRequestId: 'consumer-second',
362
+ assistantId: 'self',
363
+ }, { maxWaitMs: 0 });
364
+ expect(second.ok).toBe(false);
365
+ if (second.ok) return;
366
+ expect(second.reason).toBe('no_match');
367
+ });
368
+
369
+ test('one-time consume: tool_signature grant is consumed only once', async () => {
370
+ const digest = computeToolApprovalDigest('shell', { command: 'deploy' });
371
+ mintGrantFromDecision(
372
+ mintParams({
373
+ scopeMode: 'tool_signature',
374
+ toolName: 'shell',
375
+ inputDigest: digest,
376
+ }),
377
+ );
378
+
379
+ const first = await consumeGrantForInvocation({
380
+ toolName: 'shell',
381
+ inputDigest: digest,
382
+ consumingRequestId: 'consumer-sig-first',
383
+ assistantId: 'self',
384
+ });
385
+ expect(first.ok).toBe(true);
386
+
387
+ const second = await consumeGrantForInvocation({
388
+ toolName: 'shell',
389
+ inputDigest: digest,
390
+ consumingRequestId: 'consumer-sig-second',
391
+ assistantId: 'self',
392
+ }, { maxWaitMs: 0 });
393
+ expect(second.ok).toBe(false);
394
+ if (second.ok) return;
395
+ expect(second.reason).toBe('no_match');
396
+ });
397
+
398
+ // ---------------------------------------------------------------------------
399
+ // Context-scoped consume
400
+ // ---------------------------------------------------------------------------
401
+
402
+ test('consumes tool_signature grant with matching conversation context', async () => {
403
+ const digest = computeToolApprovalDigest('shell', { command: 'test' });
404
+ mintGrantFromDecision(
405
+ mintParams({
406
+ scopeMode: 'tool_signature',
407
+ toolName: 'shell',
408
+ inputDigest: digest,
409
+ conversationId: 'conv-ctx',
410
+ callSessionId: 'call-ctx',
411
+ }),
412
+ );
413
+
414
+ const result = await consumeGrantForInvocation({
415
+ toolName: 'shell',
416
+ inputDigest: digest,
417
+ consumingRequestId: 'consumer-ctx',
418
+ assistantId: 'self',
419
+ conversationId: 'conv-ctx',
420
+ callSessionId: 'call-ctx',
421
+ });
422
+
423
+ expect(result.ok).toBe(true);
424
+ });
425
+
426
+ test('miss: conversation context mismatch on tool_signature grant', async () => {
427
+ const digest = computeToolApprovalDigest('shell', { command: 'test' });
428
+ mintGrantFromDecision(
429
+ mintParams({
430
+ scopeMode: 'tool_signature',
431
+ toolName: 'shell',
432
+ inputDigest: digest,
433
+ conversationId: 'conv-A',
434
+ }),
435
+ );
436
+
437
+ const result = await consumeGrantForInvocation({
438
+ toolName: 'shell',
439
+ inputDigest: digest,
440
+ consumingRequestId: 'consumer-ctx-mismatch',
441
+ assistantId: 'self',
442
+ conversationId: 'conv-B',
443
+ }, { maxWaitMs: 0 });
444
+
445
+ expect(result.ok).toBe(false);
446
+ if (result.ok) return;
447
+ expect(result.reason).toBe('no_match');
448
+ });
449
+ });
450
+
451
+ // ===========================================================================
452
+ // RETRY POLLING TESTS
453
+ // ===========================================================================
454
+
455
+ describe('approval-primitive / consumeGrantForInvocation retry', () => {
456
+ beforeEach(() => clearTables());
457
+
458
+ test('succeeds immediately when grant already exists (no retry needed)', async () => {
459
+ const digest = computeToolApprovalDigest('shell', { command: 'ls' });
460
+ mintGrantFromDecision(
461
+ mintParams({
462
+ scopeMode: 'tool_signature',
463
+ toolName: 'shell',
464
+ inputDigest: digest,
465
+ }),
466
+ );
467
+
468
+ const start = Date.now();
469
+ const result = await consumeGrantForInvocation({
470
+ toolName: 'shell',
471
+ inputDigest: digest,
472
+ consumingRequestId: 'consumer-async-immediate',
473
+ assistantId: 'self',
474
+ });
475
+ const elapsed = Date.now() - start;
476
+
477
+ expect(result.ok).toBe(true);
478
+ if (!result.ok) return;
479
+ expect(result.grant.status).toBe('consumed');
480
+ // Should return nearly instantly — well under the retry interval
481
+ expect(elapsed).toBeLessThan(200);
482
+ });
483
+
484
+ test('retries and succeeds when grant appears after a delay', async () => {
485
+ const digest = computeToolApprovalDigest('shell', { command: 'delayed' });
486
+
487
+ // Mint the grant after 300ms — the async consumer should retry and find it
488
+ setTimeout(() => {
489
+ mintGrantFromDecision(
490
+ mintParams({
491
+ scopeMode: 'tool_signature',
492
+ toolName: 'shell',
493
+ inputDigest: digest,
494
+ }),
495
+ );
496
+ }, 300);
497
+
498
+ const start = Date.now();
499
+ const result = await consumeGrantForInvocation(
500
+ {
501
+ toolName: 'shell',
502
+ inputDigest: digest,
503
+ consumingRequestId: 'consumer-async-delayed',
504
+ assistantId: 'self',
505
+ },
506
+ { maxWaitMs: 5_000, intervalMs: 100 },
507
+ );
508
+ const elapsed = Date.now() - start;
509
+
510
+ expect(result.ok).toBe(true);
511
+ if (!result.ok) return;
512
+ expect(result.grant.status).toBe('consumed');
513
+ // Should have taken at least ~300ms (the delay) but less than the max wait
514
+ expect(elapsed).toBeGreaterThanOrEqual(250);
515
+ expect(elapsed).toBeLessThan(5_000);
516
+ });
517
+
518
+ test('returns failure after timeout when no grant appears', async () => {
519
+ const digest = computeToolApprovalDigest('shell', { command: 'never-minted' });
520
+
521
+ const start = Date.now();
522
+ const result = await consumeGrantForInvocation(
523
+ {
524
+ toolName: 'shell',
525
+ inputDigest: digest,
526
+ consumingRequestId: 'consumer-async-timeout',
527
+ assistantId: 'self',
528
+ },
529
+ { maxWaitMs: 500, intervalMs: 100 },
530
+ );
531
+ const elapsed = Date.now() - start;
532
+
533
+ expect(result.ok).toBe(false);
534
+ if (result.ok) return;
535
+ expect(result.reason).toBe('no_match');
536
+ // Should have waited approximately the max wait time
537
+ expect(elapsed).toBeGreaterThanOrEqual(450);
538
+ expect(elapsed).toBeLessThan(1_500);
539
+ });
540
+ });